Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
20e57d19c7 | ||
|
|
d5c8d0d0f2 | ||
|
|
2869e63d0a |
@@ -5,6 +5,7 @@ import { API_BASE, NotConnected, api } from "@/lib/api";
|
||||
import { NotConnectedPanel } from "@/components/NotConnected";
|
||||
import { PageFrame, RailCard, RailFacts } from "@/components/PageFrame";
|
||||
import { PageHeader } from "@/components/PageHeader";
|
||||
import { ManageBillingButton } from "@/components/ManageBillingButton";
|
||||
import { formatDate } from "@/lib/format";
|
||||
|
||||
export default function BillingPage() {
|
||||
@@ -51,9 +52,12 @@ export default function BillingPage() {
|
||||
</RailCard>
|
||||
<RailCard title="Need a change?">
|
||||
<p className="text-[0.82rem] text-ink-2">
|
||||
To change a card, download an invoice or cancel, email support and
|
||||
we will send you a billing link. Self-service billing arrives with
|
||||
card payments.
|
||||
Change a card, download an invoice or cancel from the billing
|
||||
portal. It covers every subscription on this account.
|
||||
</p>
|
||||
<ManageBillingButton />
|
||||
<p className="text-[0.82rem] text-ink-2">
|
||||
Anything else, email support.
|
||||
</p>
|
||||
<a
|
||||
href="mailto:support@hostxtra.co.uk"
|
||||
|
||||
@@ -5,7 +5,6 @@ import clsx from "clsx";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useEffect, useState } from "react";
|
||||
import { api, type Instance, type License } from "@/lib/api";
|
||||
import { ManageBillingButton } from "@/components/ManageBillingButton";
|
||||
import { daysRemaining, formatDate, licenceState, limitLabel } from "@/lib/format";
|
||||
import { StatePill } from "./StatePill";
|
||||
import { Button, LinkButton } from "./Button";
|
||||
@@ -195,8 +194,6 @@ export function InstanceRecord({ instance, license, reapAfterDays, defaultOpen =
|
||||
{renew.isPending ? "Renewing…" : "Renew"}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{instance.tier && instance.tier !== "free" && <ManageBillingButton />}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
services:
|
||||
# Adds to the base `server` service defined in docker-compose.yml —
|
||||
# only the cloud deployment reaps abandoned Free instances.
|
||||
server:
|
||||
environment:
|
||||
FREE_INSTANCE_REAP_AFTER: "336h"
|
||||
site:
|
||||
image: gitea.hostxtra.co.uk/mrhid6/vantage/site:latest
|
||||
restart: unless-stopped
|
||||
@@ -18,7 +13,6 @@ services:
|
||||
- 8082:8082
|
||||
environment:
|
||||
PORT: "8082"
|
||||
MONGO_URI: ${MONGO_URI:-}
|
||||
PUBLIC_URL: ${PUBLIC_URL:-}
|
||||
SITE_ORIGIN: ${SITE_ORIGIN:-}
|
||||
TRUST_PROXY: ${SITE_TRUST_PROXY:-false}
|
||||
@@ -42,12 +36,6 @@ services:
|
||||
REDIS_PASSWORD: ${REDIS_PASSWORD:-}
|
||||
LICENSE_SIGNING_KEY: ${LICENSE_SIGNING_KEY:-}
|
||||
PUBLIC_URL: ${ADMIN_PUBLIC_URL:-}
|
||||
# Comma-separated, and it needs BOTH browser origins that call admin:
|
||||
# vantage-hq (the console) and the marketing site, whose /start form
|
||||
# posts account signups here. An origin missing from this list is not
|
||||
# a 403 — admin omits the CORS header entirely and the browser blocks
|
||||
# the preflight, so the form fails with nothing in admin's logs.
|
||||
# ADMIN_ORIGIN="https://vantage-hq.hostxtra.co.uk,https://vantage.hostxtra.co.uk"
|
||||
ADMIN_ORIGIN: ${ADMIN_ORIGIN:-}
|
||||
TRUST_PROXY: ${TRUST_PROXY:-true}
|
||||
SMTP_HOST: ${SMTP_HOST:-}
|
||||
@@ -57,23 +45,13 @@ services:
|
||||
SMTP_FROM: ${SMTP_FROM:-}
|
||||
APP_LOGIN_URL: ${APP_LOGIN_URL:-}
|
||||
FREE_INSTANCE_REAP_AFTER: "336h"
|
||||
# Paddle billing. PADDLE_API_KEY and PADDLE_WEBHOOK_SECRET are
|
||||
# boot-REQUIRED — an unverified webhook endpoint is one anyone can
|
||||
# issue licences through. PADDLE_ENV selects which catalogue price
|
||||
# IDs are served (sandbox|production).
|
||||
PADDLE_ENV: ${PADDLE_ENV:-sandbox}
|
||||
PADDLE_API_KEY: ${PADDLE_API_KEY:-}
|
||||
PADDLE_WEBHOOK_SECRET: ${PADDLE_WEBHOOK_SECRET:-}
|
||||
|
||||
# The staff and customer console, served at vantage-hq.hostxtra.co.uk.
|
||||
# ADMIN_API_URL is baked into the image at build time, not read here, so
|
||||
# changing it needs a rebuild rather than a restart — and it must appear in
|
||||
# admin's ADMIN_ORIGIN above or the browser blocks every request.
|
||||
adminsite:
|
||||
image: gitea.hostxtra.co.uk/mrhid6/vantage/adminsite:latest
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
# 3000 is web, 3003 is the marketing site; this takes 3004.
|
||||
- 3004:3000
|
||||
depends_on:
|
||||
- admin
|
||||
|
||||
+60
-4
@@ -217,10 +217,13 @@ code {
|
||||
grid-template-columns: minmax(0, 0.95fr) minmax(0, 1.05fr);
|
||||
}
|
||||
|
||||
/* minmax(0, …) rather than a bare 1fr: a plain fr track is min-content floored,
|
||||
so one unshrinkable child (a form control, a long mono string) widens the
|
||||
column past the rail and scrolls the whole page sideways. */
|
||||
@media (max-width: 840px) {
|
||||
.split,
|
||||
.split--even {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -293,8 +296,7 @@ code {
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.nav__links,
|
||||
.nav__cta {
|
||||
.nav__links {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -314,7 +316,10 @@ code {
|
||||
padding-block: 0.5rem 1rem;
|
||||
}
|
||||
|
||||
.drawer a {
|
||||
/* :not(.btn) matters. Two class selectors outrank .btn--solid's one, so without
|
||||
it the drawer's call to action takes --ink-2 on the accent fill and the row
|
||||
rules meant for the links. */
|
||||
.drawer a:not(.btn) {
|
||||
text-decoration: none;
|
||||
color: var(--ink-2);
|
||||
padding: 0.6rem 0;
|
||||
@@ -852,6 +857,21 @@ code {
|
||||
font-size: 0.93rem;
|
||||
}
|
||||
|
||||
/* The key column costs a fixed 8ch or more, which on a phone leaves the prose
|
||||
in a strip barely wide enough to read. Below this width the key becomes a
|
||||
label above its own paragraph instead. */
|
||||
@media (max-width: 560px) {
|
||||
.spec {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.spec__k {
|
||||
padding-top: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- pricing ---------- */
|
||||
|
||||
.plans {
|
||||
@@ -911,6 +931,15 @@ code {
|
||||
min-height: 3.2em;
|
||||
}
|
||||
|
||||
/* The term line under the headline price. A metered plan has a second and third
|
||||
number — the annual equivalent and the per-server rate — and they are
|
||||
qualifications of the headline rather than features, so they sit with it. */
|
||||
.plan__t {
|
||||
font-size: 0.82rem;
|
||||
color: var(--ink-3);
|
||||
margin-top: -0.5rem;
|
||||
}
|
||||
|
||||
.plan ul {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
@@ -945,6 +974,28 @@ code {
|
||||
margin-top: 2.4rem;
|
||||
}
|
||||
|
||||
/* The comparison table is wider than a phone by design, and a table that scrolls
|
||||
with no scrollbar drawn looks like a table with two columns. Say so. */
|
||||
.scroll__hint {
|
||||
display: none;
|
||||
font-family: var(--mono);
|
||||
font-size: var(--s--1);
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ink-3);
|
||||
margin-top: 2.4rem;
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.scroll__hint {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.scroll__hint + .scroll {
|
||||
margin-top: 0.7rem;
|
||||
}
|
||||
}
|
||||
|
||||
table.cmp {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
@@ -1015,6 +1066,10 @@ table.cmp {
|
||||
font-size: 0.79rem;
|
||||
}
|
||||
|
||||
/* min-width: 0 is load-bearing on mobile. A bare <input> carries an intrinsic
|
||||
width of roughly 20 characters, and width: 100% does not lower it, so the
|
||||
control sets the min-content width of every ancestor and drags the page wider
|
||||
than the viewport. */
|
||||
.field input,
|
||||
.field select,
|
||||
.field textarea {
|
||||
@@ -1026,6 +1081,7 @@ table.cmp {
|
||||
border-radius: 3px;
|
||||
padding: 0.6rem 0.72rem;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.field textarea {
|
||||
|
||||
+163
-47
@@ -4,21 +4,27 @@ import type { Metadata } from "next";
|
||||
export const metadata: Metadata = {
|
||||
title: "Pricing",
|
||||
description:
|
||||
"Priced per managed server. People, keys, workflows and secrets are free. Free for 3 servers, £4 per server per month, or £290 a year self-hosted.",
|
||||
"Three tiers, cloud or self-hosted, priced per managed server. Free for 3 servers. Professional from £19 a month, Enterprise from £49 a month, £5 per extra server.",
|
||||
};
|
||||
|
||||
/* Prices and allowances are the shipped catalogue, not marketing rounding:
|
||||
* allowances come from shared/license/plans.go and the amounts from the Paddle
|
||||
* catalogue. Annual is ten times monthly everywhere, which is the "two months
|
||||
* free" the portal's own term picker states. */
|
||||
const COMPARISON: [string, string, string, string][] = [
|
||||
["Managed servers", "3", "Unlimited", "Unlimited"],
|
||||
["Members", "1", "Unlimited", "Unlimited"],
|
||||
["Managed servers", "3", "3, then £5 each", "10, then £5 each"],
|
||||
["People", "Unlimited", "Unlimited", "Unlimited"],
|
||||
["SSH key assignment", "Yes", "Yes", "Yes"],
|
||||
["Workflows and step library", "Yes", "Yes", "Yes"],
|
||||
["Monitors", "3", "Unlimited", "Unlimited"],
|
||||
["Secrets vault", "No", "Yes", "Yes"],
|
||||
["Browser console", "No", "Yes", "Yes"],
|
||||
["OIDC single sign-on", "No", "Yes", "Yes"],
|
||||
["Audit history", "30 days", "Forever", "Forever"],
|
||||
["Runs on your hardware", "No", "No", "Yes"],
|
||||
["Support", "Community", "Next business day", "Priority"],
|
||||
["Secret groups", "1", "Unlimited", "Unlimited"],
|
||||
["Notification channels", "1", "Unlimited", "Unlimited"],
|
||||
["Audit history", "30 days", "365 days", "Forever"],
|
||||
["Browser console", "Not Available", "Add-on", "Add-on"],
|
||||
["Single sign-on", "Not Available", "Add-on", "Add-on"],
|
||||
["Support", "Community", "Email, 24/5", "Email and phone, 24/7"],
|
||||
["Cloud term", "Annual, £0", "Monthly or annual", "Monthly or annual"],
|
||||
["Self-hosted term", "Annual, £0", "Annual", "Annual"],
|
||||
];
|
||||
|
||||
export default function PricingPage() {
|
||||
@@ -32,30 +38,36 @@ export default function PricingPage() {
|
||||
maxWidth: "17ch",
|
||||
}}
|
||||
>
|
||||
Per managed server. Nothing else counts.
|
||||
Pick a tier, then pay per server.
|
||||
</h1>
|
||||
<p className="lede">
|
||||
People are free. Keys, workflows, monitors and secrets are free.
|
||||
You pay for servers running an agent, because that is the only
|
||||
number that grows with you.
|
||||
You pay for a tier and for the servers running an agent, because
|
||||
that is the only number that grows with you. Every tier runs
|
||||
either in our cloud or on your own hardware, for the same money.
|
||||
</p>
|
||||
|
||||
<div className="plans">
|
||||
<div className="plan">
|
||||
<div>
|
||||
<div className="plan__n">Solo</div>
|
||||
<div className="plan__n">Free</div>
|
||||
<p className="plan__d">
|
||||
A homelab, a couple of VPSes, and the keys on your
|
||||
own laptop.
|
||||
</p>
|
||||
</div>
|
||||
<div className="plan__p">
|
||||
£0 <span>forever</span>
|
||||
£0 <span>3 servers</span>
|
||||
</div>
|
||||
<p className="plan__t">
|
||||
Cloud or self-hosted. Renewed once a year from the
|
||||
portal, one free instance per deployment.
|
||||
</p>
|
||||
<ul>
|
||||
<li>Up to 3 servers</li>
|
||||
<li>Keys, workflows and monitors</li>
|
||||
<li>One member, one instance</li>
|
||||
<li>3 servers and 3 monitors</li>
|
||||
<li>Keys, workflows and the step library</li>
|
||||
<li>One secret group, one alert channel</li>
|
||||
<li>30 days of audit history</li>
|
||||
<li>Community support</li>
|
||||
</ul>
|
||||
<Link className="btn btn--line" href="/start">
|
||||
@@ -72,62 +84,107 @@ export default function PricingPage() {
|
||||
</p>
|
||||
</div>
|
||||
<div className="plan__p">
|
||||
£4 <span>/ server / month</span>
|
||||
£19 <span>/ month, 3 servers</span>
|
||||
</div>
|
||||
<p className="plan__t">
|
||||
£5 per extra server. £190 a year saves two months.
|
||||
Self-hosted is £190 a year.
|
||||
</p>
|
||||
<ul>
|
||||
<li>Unlimited servers and members</li>
|
||||
<li>Owner, admin and member roles</li>
|
||||
<li>OIDC single sign-on</li>
|
||||
<li>Browser console and secrets vault</li>
|
||||
<li>Full audit history</li>
|
||||
<li>Email support, next business day</li>
|
||||
<li>3 servers included, add as many as you like</li>
|
||||
<li>Unlimited monitors, secrets and channels</li>
|
||||
<li>365 days of audit history</li>
|
||||
<li>Browser console and single sign-on as add-ons</li>
|
||||
<li>Email support, 24/5</li>
|
||||
</ul>
|
||||
<Link className="btn btn--solid" href="/start">
|
||||
Start 14-day trial
|
||||
Get started
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="plan">
|
||||
<div>
|
||||
<div className="plan__n">Self-hosted</div>
|
||||
<div className="plan__n">Enterprise</div>
|
||||
<p className="plan__d">
|
||||
The whole stack on your metal, behind your own
|
||||
boundary.
|
||||
A fleet, a compliance question, and someone who
|
||||
wants a phone number.
|
||||
</p>
|
||||
</div>
|
||||
<div className="plan__p">
|
||||
£290 <span>/ year, per install</span>
|
||||
£49 <span>/ month, 10 servers</span>
|
||||
</div>
|
||||
<p className="plan__t">
|
||||
£5 per extra server. £490 a year saves two months.
|
||||
Self-hosted is £490 a year.
|
||||
</p>
|
||||
<ul>
|
||||
<li>Everything in Professional, no server cap</li>
|
||||
<li>Your MongoDB, Redis and certificates</li>
|
||||
<li>Mirror agent releases internally</li>
|
||||
<li>Priority support and upgrade notes</li>
|
||||
<li>10 servers included, add as many as you like</li>
|
||||
<li>Everything in Professional</li>
|
||||
<li>Audit history kept forever</li>
|
||||
<li>Email and phone support, 24/7</li>
|
||||
<li>Buy it yourself, no sales call</li>
|
||||
</ul>
|
||||
<Link className="btn btn--line" href="/contact">
|
||||
Talk to us
|
||||
<Link className="btn btn--line" href="/start">
|
||||
Get started
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="scroll">
|
||||
<div
|
||||
className="specs"
|
||||
style={{ marginTop: "2.6rem", maxWidth: "66ch" }}
|
||||
>
|
||||
<span className="tag">
|
||||
Add-ons, on Professional and Enterprise
|
||||
</span>
|
||||
<div className="spec">
|
||||
<span className="spec__k">£5 / MO</span>
|
||||
<div>
|
||||
<h3>Browser console</h3>
|
||||
<p>
|
||||
SSH, RDP and VNC sessions in the browser, audited
|
||||
like everything else. £50 a year.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="spec">
|
||||
<span className="spec__k">£9 / MO</span>
|
||||
<div>
|
||||
<h3>Single sign-on</h3>
|
||||
<p>
|
||||
Connect your own OIDC provider to an instance. £90 a
|
||||
year.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="scroll__hint">
|
||||
Scroll the table sideways for all three tiers
|
||||
</p>
|
||||
<div
|
||||
className="scroll"
|
||||
tabIndex={0}
|
||||
role="region"
|
||||
aria-label="Plan comparison"
|
||||
>
|
||||
<table className="cmp">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Capability</th>
|
||||
<th>Solo</th>
|
||||
<th>Free</th>
|
||||
<th>Professional</th>
|
||||
<th>Self-hosted</th>
|
||||
<th>Enterprise</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{COMPARISON.map(
|
||||
([capability, solo, professional, selfHosted]) => (
|
||||
([capability, free, professional, enterprise]) => (
|
||||
<tr key={capability}>
|
||||
<td>{capability}</td>
|
||||
<td>{solo}</td>
|
||||
<td>{free}</td>
|
||||
<td>{professional}</td>
|
||||
<td>{selfHosted}</td>
|
||||
<td>{enterprise}</td>
|
||||
</tr>
|
||||
),
|
||||
)}
|
||||
@@ -143,19 +200,69 @@ export default function PricingPage() {
|
||||
<div>
|
||||
<h3>What counts as a server</h3>
|
||||
<p>
|
||||
One running agent, one server. Remove a box and
|
||||
it stops billing that day.
|
||||
One running agent, one server. You choose the
|
||||
cap rather than being metered on what you ran,
|
||||
so the invoice is the same number you set.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="spec">
|
||||
<span className="spec__k">ADDING</span>
|
||||
<div>
|
||||
<h3>Going up mid-term</h3>
|
||||
<p>
|
||||
Raise the server count and the new cap applies
|
||||
straight away, prorated for the rest of the
|
||||
period. Your renewal date does not move.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="spec">
|
||||
<span className="spec__k">REDUCING</span>
|
||||
<div>
|
||||
<h3>Going down</h3>
|
||||
<p>
|
||||
A reduction takes effect at your next renewal.
|
||||
You keep what you have already paid for until
|
||||
then, and we show you the date it changes.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="spec">
|
||||
<span className="spec__k">LIMITS</span>
|
||||
<div>
|
||||
<h3>Going over on Solo</h3>
|
||||
<h3>Reaching the cap</h3>
|
||||
<p>
|
||||
Nothing is deleted. A fourth agent registers and
|
||||
heartbeats, but stops syncing keys until you
|
||||
upgrade or remove a server.
|
||||
Nothing is deleted. An agent beyond the cap
|
||||
still registers and heartbeats, but stops
|
||||
syncing keys until you raise the cap or remove a
|
||||
server.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="spec">
|
||||
<span className="spec__k">CANCELLING</span>
|
||||
<div>
|
||||
<h3>What happens when you stop paying</h3>
|
||||
<p>
|
||||
Your licence runs to the end of the period you
|
||||
paid for, then the instance goes read-only.
|
||||
Monitors keep checking, alerts keep firing and
|
||||
agents keep their keys — you just cannot change
|
||||
anything until you renew.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="spec">
|
||||
<span className="spec__k">SELF-HOSTED</span>
|
||||
<div>
|
||||
<h3>Why self-hosted is annual only</h3>
|
||||
<p>
|
||||
A self-hosted licence is a signed file your
|
||||
install checks offline, with no phone home. That
|
||||
means we cannot withdraw one early, so the term
|
||||
is the limit — and we would rather bound it at a
|
||||
year than sell it twelve times over.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -173,6 +280,15 @@ export default function PricingPage() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="prose" style={{ marginTop: "2.4rem" }}>
|
||||
Prices exclude VAT, which is calculated at checkout. Billing is
|
||||
handled by Paddle as merchant of record.{" "}
|
||||
<Link href="/contact" style={{ color: "var(--accent)" }}>
|
||||
Ask us anything
|
||||
</Link>{" "}
|
||||
before you buy.
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -48,10 +48,6 @@ export function Nav() {
|
||||
>
|
||||
{open ? "Close" : "Menu"}
|
||||
</button>
|
||||
|
||||
<Link className="btn btn--solid btn--sm nav__cta" href="/start">
|
||||
Get started
|
||||
</Link>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
||||
@@ -13,32 +13,14 @@ import (
|
||||
"github.com/joho/godotenv"
|
||||
"github.com/mrhid6/vantage/sitesvc/internal/api"
|
||||
"github.com/mrhid6/vantage/sitesvc/internal/mail"
|
||||
"github.com/mrhid6/vantage/sitesvc/internal/store"
|
||||
)
|
||||
|
||||
func main() {
|
||||
|
||||
godotenv.Load()
|
||||
|
||||
mongoURI := getEnv("MONGO_URI", "mongodb://localhost:27017/vantage")
|
||||
addr := ":" + getEnv("PORT", "8082")
|
||||
|
||||
if err := store.Connect(mongoURI); err != nil {
|
||||
log.Fatalf("failed to connect to MongoDB: %v", err)
|
||||
}
|
||||
log.Printf("connected to MongoDB (database %q)", store.DatabaseName())
|
||||
|
||||
guardCtx, guardCancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
guardErr := store.RequireMigratedDatabase(guardCtx)
|
||||
guardCancel()
|
||||
if guardErr != nil {
|
||||
log.Fatalf("database check failed: %v", guardErr)
|
||||
}
|
||||
|
||||
if err := store.EnsureIndexes(); err != nil {
|
||||
log.Fatalf("failed to ensure indexes: %v", err)
|
||||
}
|
||||
|
||||
mailCfg := mail.FromEnv()
|
||||
if mailCfg.Enabled() {
|
||||
log.Printf("smtp enabled (%s) contact form delivers to %s", mailCfg.Host, mailCfg.To)
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/mrhid6/vantage/shared/indexes"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo/options"
|
||||
"go.mongodb.org/mongo-driver/v2/x/mongo/driver/connstring"
|
||||
)
|
||||
|
||||
var database *mongo.Database
|
||||
|
||||
func Connect(uri string) error {
|
||||
cs, err := connstring.ParseAndValidate(uri)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse MONGO_URI: %w", err)
|
||||
}
|
||||
if cs.Database == "" {
|
||||
return errors.New("MONGO_URI must name a database, e.g. mongodb://host:27017/vantage")
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
client, err := mongo.Connect(options.Client().ApplyURI(uri))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := client.Ping(ctx, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
database = client.Database(cs.Database)
|
||||
return nil
|
||||
}
|
||||
|
||||
func DatabaseName() string {
|
||||
if database == nil {
|
||||
return ""
|
||||
}
|
||||
return database.Name()
|
||||
}
|
||||
|
||||
func col(name string) *mongo.Collection { return database.Collection(name) }
|
||||
|
||||
func EnsureIndexes() error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// users.email and instances.slug are declared in the shared module so both
|
||||
// services agree. Re-declaring at boot means sitesvc does not depend on the
|
||||
// control plane having started first.
|
||||
return indexes.EnsureCoreIndexes(ctx, database)
|
||||
}
|
||||
|
||||
// RequireMigratedDatabase refuses to start against a control-plane database
|
||||
// that has not run migration 0004.
|
||||
//
|
||||
// Provisioning into `orgs` while the control plane reads `instances` would
|
||||
// create tenants nobody can see — the exact skew failure the shared module was
|
||||
// built to prevent. Failing to start is strictly better.
|
||||
func RequireMigratedDatabase(ctx context.Context) error {
|
||||
names, err := database.ListCollectionNames(ctx, bson.M{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("list collections: %w", err)
|
||||
}
|
||||
|
||||
var hasInstances, hasOrgs bool
|
||||
for _, n := range names {
|
||||
switch n {
|
||||
case "instances":
|
||||
hasInstances = true
|
||||
case "orgs":
|
||||
hasOrgs = true
|
||||
}
|
||||
}
|
||||
|
||||
// A brand-new database has neither. That is fine — whichever service starts
|
||||
// first creates `instances`.
|
||||
if !hasInstances && !hasOrgs {
|
||||
return nil
|
||||
}
|
||||
if !hasInstances {
|
||||
return errors.New("instances collection not found; deploy the control plane first")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user