Shared Go module extraction (spec 0a) and the Org to Instance rename (spec 0b). - shared/ module holds the documents and provisioning rules the control plane and sitesvc both write; sitesvc's hand-copied duplicates are deleted - Org becomes Instance everywhere, including the org_id field on all 17 tenant-scoped collections, via migration 0004_org_to_instance - cmd/rename-rollback reverses the migration - Go images now build from the repo root so the replace directive resolves Migration verified against a seeded legacy fixture: lossless, tenant-isolated, idempotent, resumable and reversible. NOT yet rehearsed against a production snapshot, which plan 0b requires before deploying.
This commit is contained in:
@@ -24,7 +24,8 @@ jobs:
|
||||
- name: Build and push server image
|
||||
run: |
|
||||
IMAGE="${{ vars.DOCKER_HOST }}/${{ github.repository_owner }}/vantage/server:latest"
|
||||
docker build -t "$IMAGE" -f server/Dockerfile server/
|
||||
# Root context: server depends on the shared module.
|
||||
docker build -t "$IMAGE" -f server/Dockerfile .
|
||||
docker push "$IMAGE"
|
||||
|
||||
- name: Build and push web image
|
||||
@@ -49,5 +50,6 @@ jobs:
|
||||
- name: Build and push sitesvc image
|
||||
run: |
|
||||
IMAGE="${{ vars.DOCKER_HOST }}/${{ github.repository_owner }}/vantage/sitesvc:latest"
|
||||
docker build -t "$IMAGE" -f sitesvc/Dockerfile sitesvc/
|
||||
# Root context: sitesvc depends on the shared module.
|
||||
docker build -t "$IMAGE" -f sitesvc/Dockerfile .
|
||||
docker push "$IMAGE"
|
||||
|
||||
+2
-1
@@ -2,7 +2,8 @@ node_modules
|
||||
dist
|
||||
build
|
||||
.env
|
||||
docs
|
||||
docs/*
|
||||
!docs/superpowers/
|
||||
.superpowers
|
||||
installer/vantage-agent-windows-amd64.exe
|
||||
installer/*.msi
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
//go:build !linux
|
||||
|
||||
|
||||
// Inventory collection is Linux-only. This no-op stands in everywhere else.
|
||||
//
|
||||
// The build constraint above is load-bearing: "_other" is not a GOOS suffix, so
|
||||
// without it this file compiles on Linux too and collides with collect_linux.go.
|
||||
package inventory
|
||||
|
||||
import "github.com/mrhid6/vantage/agent/internal/grpc/pb"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,410 @@
|
||||
# Spec 3 — Admin Backend
|
||||
|
||||
Date: 2026-07-24
|
||||
Status: Design approved, not implemented
|
||||
Depends on: spec 0a, spec 0b, spec 1 (`licensing-core`)
|
||||
Ships: with spec 4 (the site it serves). Blocks specs 4 and 5.
|
||||
|
||||
## Context
|
||||
|
||||
A fourth Go service, `admin/`, owning customers, instances, licences and
|
||||
subscriptions. It is the only service that holds the signing key.
|
||||
|
||||
Its data model separates two things the control plane deliberately does not know
|
||||
about:
|
||||
|
||||
- **Account** — a paying customer. Holds a Paddle customer, a billing email, and
|
||||
one or more instances.
|
||||
- **Instance** — one deployment. Cloud instances mirror a control-plane
|
||||
`Instance` row; self-hosted instances exist only here, because the customer's
|
||||
database is theirs and we cannot see it.
|
||||
|
||||
## Goals
|
||||
|
||||
1. Issue, store and re-issue licences, with full history.
|
||||
2. Inject licences into cloud instances.
|
||||
3. Serve both staff and customers, with the right things hidden from each.
|
||||
4. Never be a runtime dependency of a Vantage instance. If admin is down,
|
||||
every instance keeps working; only purchasing and renewals stop.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- The UI. Spec 4.
|
||||
- Paddle. Spec 5. This spec defines the `subscriptions` table and the issuance
|
||||
functions that spec 5's webhooks call, and nothing more.
|
||||
- Rebuilding billing management. Card changes, invoices and cancellation go to
|
||||
Paddle's own customer portal.
|
||||
|
||||
## Design
|
||||
|
||||
### Module
|
||||
|
||||
```
|
||||
admin/
|
||||
├── go.mod # replace => ../shared
|
||||
├── cmd/main.go
|
||||
└── internal/
|
||||
├── api/ # gin handlers
|
||||
├── auth/ # staff, cloud-customer and local-customer sessions
|
||||
├── db/ # two connections: admin DB and control-plane DB
|
||||
├── models/ # admin-owned documents
|
||||
├── licensing/ # issuance, renewal, relink
|
||||
├── inject/ # control-plane writes
|
||||
├── mail/ # licence delivery
|
||||
└── paddle/ # spec 5 lands here
|
||||
```
|
||||
|
||||
Port `8083`. In `deploy/docker-compose.site.yml` only — like sitesvc, admin is
|
||||
**excluded from the self-hosted deployment**. A self-hosted customer runs
|
||||
instances, not the licensing authority.
|
||||
|
||||
### Two database connections
|
||||
|
||||
The service holds two:
|
||||
|
||||
- `ADMIN_MONGO_URI` — its own database, `vantage_admin`. Sole owner.
|
||||
- `CONTROL_MONGO_URI` — the control plane's database, used to write licence
|
||||
fields onto cloud instance documents and to authenticate cloud customers.
|
||||
|
||||
The control-plane connection uses the `Instance` and `User` structs from
|
||||
`shared` (spec 0a). This is what makes direct writes safe: there is no admin-side
|
||||
copy of the document shape to drift, which is the coupling hazard sitesvc used to
|
||||
carry.
|
||||
|
||||
Admin's control-plane access is **narrow by construction**: it reads
|
||||
`instances` and `users`, and it writes exactly three fields on `instances`. The
|
||||
Mongo credential it is given should be scoped to that where the deployment allows
|
||||
it. It must never write to any other collection.
|
||||
|
||||
### Data model
|
||||
|
||||
```go
|
||||
type Account struct {
|
||||
ID bson.ObjectID
|
||||
AccountID string // uuid
|
||||
Name string
|
||||
BillingEmail string
|
||||
PaddleCustomerID string // empty until first checkout
|
||||
Status string // active | suspended
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type Instance struct {
|
||||
ID bson.ObjectID
|
||||
InstanceID string // for cloud: equals the control-plane instance_id
|
||||
// for self-hosted: the UUID the customer pasted
|
||||
AccountID string
|
||||
Name string
|
||||
Slug string // cloud only; the subdomain label
|
||||
Deployment string // cloud | self_hosted
|
||||
Tier string
|
||||
Status string // awaiting_link | active | lapsed | cancelled
|
||||
CurrentLicense string // licence ID
|
||||
RelinkCount int // reset each term
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type License struct {
|
||||
ID bson.ObjectID
|
||||
LicenseID string
|
||||
InstanceID string
|
||||
AccountID string
|
||||
Tier string
|
||||
Deployment string
|
||||
Limits license.Limits // snapshot
|
||||
Features []string // snapshot
|
||||
IssuedAt time.Time
|
||||
ExpiresAt time.Time
|
||||
Blob string
|
||||
SupersededBy string // licence ID, when replaced
|
||||
IssuedBy string // staff user, "system", or "paddle:<event id>"
|
||||
Reason string // new | renewal | tier_change | relink | manual
|
||||
}
|
||||
|
||||
type Subscription struct {
|
||||
ID bson.ObjectID
|
||||
SubscriptionID string
|
||||
AccountID string
|
||||
InstanceID string
|
||||
PaddleSubscriptionID string
|
||||
PaddlePriceID string
|
||||
Tier string
|
||||
Term string // monthly | annual
|
||||
Status string // active | past_due | cancelled | awaiting_link
|
||||
CurrentPeriodEnd time.Time
|
||||
}
|
||||
|
||||
type Plan struct {
|
||||
Tier string
|
||||
Name string
|
||||
Deployment string
|
||||
Limits license.Limits
|
||||
Features []string
|
||||
PaddleProductID string
|
||||
PaddlePriceIDs map[string]string // "monthly" | "annual"
|
||||
Active bool
|
||||
}
|
||||
```
|
||||
|
||||
Collections: `accounts`, `admin_instances`, `licenses`, `subscriptions`,
|
||||
`plans`, `staff_users`, `customer_users`, `admin_audit`.
|
||||
|
||||
Unique indexes: `accounts.account_id`, `admin_instances.instance_id`,
|
||||
`licenses.license_id`, `subscriptions.paddle_subscription_id`, `plans.tier`,
|
||||
`staff_users.email`, `customer_users.email`.
|
||||
|
||||
`admin_instances.instance_id` unique is load-bearing: it is what stops the same
|
||||
self-hosted UUID being linked to two accounts.
|
||||
|
||||
**Licences are append-only.** A renewal writes a new row and sets
|
||||
`SupersededBy` on the old one. Nothing is ever edited or deleted. When a support
|
||||
question arrives about why a customer's instance stopped working on a given
|
||||
date, the answer is in the table.
|
||||
|
||||
`plans` holds tier contents so they change without a deploy, seeded from the
|
||||
table in spec 1. Every issued licence snapshots the plan, so editing a plan never
|
||||
changes an existing licence — the same rule as `workflow_runs.steps_snapshot`.
|
||||
|
||||
### Issuance
|
||||
|
||||
```go
|
||||
func Issue(ctx, instanceID, tier, term, reason, issuedBy string) (*models.License, error)
|
||||
```
|
||||
|
||||
1. Load the instance and its account.
|
||||
2. Load the plan for `tier`; refuse if `plan.Deployment != instance.Deployment`.
|
||||
**This is the check that makes Free cloud-only** — Free's plan is
|
||||
`deployment: cloud`, so it can never be issued to a self-hosted instance.
|
||||
3. Build the payload with the instance's UUID bound in, `ExpiresAt` from the term
|
||||
plus a **3-day grace** so a renewal webhook arriving slightly late does not
|
||||
create a gap.
|
||||
4. Sign with `LICENSE_SIGNING_KEY`.
|
||||
5. Insert the licence row; set `SupersededBy` on the previous one; update
|
||||
`instance.CurrentLicense` and `instance.Tier`.
|
||||
6. If cloud, inject. If self-hosted, email the blob and make it downloadable.
|
||||
7. Write an `admin_audit` entry.
|
||||
|
||||
Steps 5 and 6 are not transactional. Order matters: **record first, deliver
|
||||
second.** A licence recorded but not delivered is recoverable — the customer
|
||||
downloads it. A licence delivered but not recorded is a support mystery.
|
||||
|
||||
### Free tier rule
|
||||
|
||||
One Free instance per account, enforced in `Issue`: refuse a second Free instance
|
||||
for an account that already has one that is not `cancelled`. Additional
|
||||
instances must be paid.
|
||||
|
||||
### Injection
|
||||
|
||||
```go
|
||||
func InjectCloud(ctx, instanceID string, lic *models.License) error
|
||||
```
|
||||
|
||||
Writes `license_blob`, `license_tier`, `license_expiry` onto the control-plane
|
||||
`instances` document via a single `UpdateOne`. Idempotent, retryable, and safe to
|
||||
re-run.
|
||||
|
||||
Retries three times with backoff; on final failure the licence stays recorded and
|
||||
`instance.Status` is set to `active` regardless, with the failure logged and
|
||||
surfaced as a staff alert. A **reconciliation job runs every 15 minutes**,
|
||||
comparing each cloud instance's `CurrentLicense` against the blob actually stored
|
||||
in the control plane, and re-injecting on mismatch. That job, not the webhook, is
|
||||
what guarantees eventual consistency.
|
||||
|
||||
The control-plane instance caches licence state for 60 seconds (spec 2), so an
|
||||
injection takes effect within a minute without a restart.
|
||||
|
||||
### Self-hosted linking
|
||||
|
||||
The flow, end to end:
|
||||
|
||||
```
|
||||
Customer runs /setup on their own install → instance UUID generated and shown
|
||||
Customer buys Self Hosted in the admin site → subscription created,
|
||||
status awaiting_link
|
||||
Customer pastes the UUID into the admin site → admin_instances row created,
|
||||
status active
|
||||
Admin issues the licence with that UUID bound in
|
||||
Customer downloads the .lic file or copies the blob
|
||||
Customer pastes it into /settings/license on their install
|
||||
```
|
||||
|
||||
Validation on link: the UUID must parse as a UUID, must not already exist in
|
||||
`admin_instances`, and must not collide with a cloud instance ID. A duplicate
|
||||
returns "That instance ID is already linked to an account" without revealing
|
||||
which — it is a small enumeration surface but there is no reason to leave it
|
||||
open.
|
||||
|
||||
### Relink
|
||||
|
||||
A rebuilt server has a new UUID. `POST /api/instances/:id/relink` with the new
|
||||
UUID:
|
||||
|
||||
- Allowed **3 times per term**, `RelinkCount` reset on renewal.
|
||||
- Updates `admin_instances.instance_id`, issues a replacement licence for the
|
||||
**remaining term** with `reason: relink`, supersedes the old one.
|
||||
- The old licence is not revoked — it cannot be, offline verification has no
|
||||
revocation. It simply no longer matches any UUID the customer controls, and its
|
||||
binding stops it being useful on a different machine anyway.
|
||||
- Beyond 3, the endpoint returns a message directing the customer to support, and
|
||||
staff can relink without limit.
|
||||
|
||||
`RelinkCount` is the abuse signal, not the abuse prevention. Its real job is to
|
||||
put a human in front of the fourth attempt.
|
||||
|
||||
### Authentication
|
||||
|
||||
Three identities, three paths, one session store (Redis, `admin_session`
|
||||
cookie, 24h).
|
||||
|
||||
**Staff** — `staff_users`, local email plus bcrypt. Full access. Created by CLI
|
||||
only; there is no staff signup.
|
||||
|
||||
**Cloud customers** — authenticate against the control plane's `users`
|
||||
collection with the credentials they already use. Admin looks the user up
|
||||
by email, checks bcrypt, resolves their control-plane instance, then resolves the
|
||||
account that owns it.
|
||||
|
||||
Two consequences, stated plainly because they are real:
|
||||
|
||||
1. A cloud user's control-plane password now also unlocks billing. Any password
|
||||
change or compromise has a wider blast radius than before.
|
||||
2. Only users with control-plane role `owner` may sign in to the admin site.
|
||||
`admin` and `member` are refused. Billing is an owner concern.
|
||||
|
||||
Mitigations: rate-limit to 5 attempts per email per 15 minutes and 20 per IP per
|
||||
hour; log every attempt to `admin_audit`; return an identical error for unknown
|
||||
email and wrong password.
|
||||
|
||||
**Self-hosted customers** — `customer_users`, local email plus bcrypt at cost 12,
|
||||
created during purchase, scoped to one account. Email verification reuses the
|
||||
pattern sitesvc already proved: 32 random bytes, only the SHA-256 hash stored,
|
||||
24-hour expiry, TTL index.
|
||||
|
||||
A single email address could in principle be both a cloud user and a
|
||||
self-hosted customer user. `customer_users` is checked first; if it matches, that
|
||||
identity wins. Documented so the behaviour is chosen rather than emergent.
|
||||
|
||||
### API
|
||||
|
||||
Staff:
|
||||
|
||||
```
|
||||
GET /api/staff/accounts list, search
|
||||
POST /api/staff/accounts
|
||||
GET /api/staff/accounts/:id
|
||||
GET /api/staff/instances filter by account, deployment, status, expiry
|
||||
POST /api/staff/instances/:id/issue manual issue or reissue
|
||||
POST /api/staff/instances/:id/relink no limit
|
||||
GET /api/staff/licenses full history, filterable
|
||||
GET /api/staff/plans
|
||||
PUT /api/staff/plans/:tier
|
||||
GET /api/staff/audit
|
||||
GET /api/staff/health/injection reconciliation status and failures
|
||||
```
|
||||
|
||||
Customer:
|
||||
|
||||
```
|
||||
GET /api/account own account and instances
|
||||
POST /api/instances/link self-hosted UUID link
|
||||
POST /api/instances/:id/relink rate-limited
|
||||
GET /api/instances/:id/license current licence metadata
|
||||
GET /api/instances/:id/license/download .lic file
|
||||
GET /api/subscriptions status, next renewal
|
||||
POST /api/billing/portal Paddle portal redirect (spec 5)
|
||||
```
|
||||
|
||||
Every customer handler resolves the account from the session and scopes by it.
|
||||
The scoping is enforced by a helper every handler calls, not by each handler
|
||||
remembering — the same deny-by-default reasoning as spec 2's middleware.
|
||||
|
||||
### Configuration
|
||||
|
||||
| Variable | Required | Notes |
|
||||
|---|---|---|
|
||||
| `ADMIN_MONGO_URI` | yes | admin's own database; name read from the URI path, refused if absent |
|
||||
| `CONTROL_MONGO_URI` | yes | control-plane database, for injection and cloud auth |
|
||||
| `REDIS_ADDR` | yes | sessions |
|
||||
| `LICENSE_SIGNING_KEY` | yes | ed25519 private key hex. **Boot fails without it** — a licensing service that cannot sign is worse than one that is down, because it looks healthy |
|
||||
| `PUBLIC_URL` | yes | for verification and licence links |
|
||||
| `SMTP_*` | yes | licence delivery |
|
||||
| `ADMIN_ORIGIN` | yes | CORS allow-list |
|
||||
| `TRUST_PROXY` | no | only behind a proxy that overwrites `X-Forwarded-For` |
|
||||
| Paddle variables | spec 5 | |
|
||||
|
||||
### Backfill
|
||||
|
||||
Licences issued by `lkctl` during the spec 1–2 period exist only as blobs.
|
||||
A one-shot `admin backfill --from=blobs.json` parses each with
|
||||
`license.Parse`, creates the account, instance and licence rows, and marks them
|
||||
`reason: manual`. Run once when admin goes live.
|
||||
|
||||
## Testing
|
||||
|
||||
**Issuance:**
|
||||
|
||||
1. `Issue` produces a licence that `license.Verify` accepts for that instance.
|
||||
2. Deployment mismatch (Free plan, self-hosted instance) is refused.
|
||||
3. A second Free instance for the same account is refused; a third paid one is
|
||||
allowed.
|
||||
4. Renewal supersedes the previous licence and leaves it in the table.
|
||||
5. The issued licence snapshots the plan; editing the plan afterwards does not
|
||||
change the issued licence.
|
||||
6. Grace period: `ExpiresAt` is term end plus 3 days.
|
||||
|
||||
**Injection:**
|
||||
|
||||
7. `InjectCloud` writes all three fields; the control plane then reports `valid`.
|
||||
8. Injection is idempotent across two calls.
|
||||
9. Injection failure leaves the licence recorded and flags the instance.
|
||||
10. The reconciliation job detects a control-plane blob that does not match
|
||||
`CurrentLicense` and re-injects.
|
||||
|
||||
**Linking and relink:**
|
||||
|
||||
11. Linking an unknown UUID succeeds; linking one already linked is refused.
|
||||
12. Relink issues a licence for the *remaining* term, not a fresh full term.
|
||||
13. The fourth relink in a term is refused for a customer and allowed for staff.
|
||||
14. `RelinkCount` resets on renewal.
|
||||
|
||||
**Auth:**
|
||||
|
||||
15. Cloud owner signs in with control-plane credentials; `admin` and `member`
|
||||
roles are refused.
|
||||
16. Unknown email and wrong password return identical errors and timing is not a
|
||||
meaningful oracle.
|
||||
17. Rate limits trigger at the documented thresholds.
|
||||
18. Self-hosted customer cannot sign in before verifying their email.
|
||||
19. A customer requesting another account's instance gets `404`, not `403` —
|
||||
no existence disclosure.
|
||||
|
||||
**Scoping:**
|
||||
|
||||
20. Every customer endpoint, called with a session for account A against a
|
||||
resource of account B, returns `404`. Written as a table-driven test over the
|
||||
route list so a new endpoint that forgets to scope fails the build.
|
||||
|
||||
## Verification before merge
|
||||
|
||||
1. Full suite green, including the scoping table test (test 20).
|
||||
2. End to end, cloud: create account → create instance → issue Professional →
|
||||
confirm the control-plane instance reports `valid` within 60 seconds with no
|
||||
restart.
|
||||
3. End to end, self-hosted: run `/setup` on a scratch install, copy the UUID,
|
||||
link it, issue, download, paste, confirm `valid`.
|
||||
4. Confirm admin's control-plane credential cannot write to `servers`, `keys` or
|
||||
any collection other than `instances`.
|
||||
5. Kill the admin service and confirm every Vantage instance keeps working
|
||||
entirely normally.
|
||||
|
||||
## Risks
|
||||
|
||||
| Risk | Mitigation |
|
||||
|---|---|
|
||||
| Admin becomes a runtime dependency | Verification step 5; instances verify offline and never call admin |
|
||||
| Signing key exposure | Single service, single variable, never in an image; rotation path from spec 1 |
|
||||
| Cloud password now unlocks billing | Owner-only, rate-limited, audited, and stated in the release notes |
|
||||
| Injection silently fails | Reconciliation every 15 minutes plus a staff health endpoint |
|
||||
| Admin writes outside its remit in the control plane | Narrow code path; scoped Mongo credential; reviewed on every change |
|
||||
| Self-hosted UUID squatted by another account | Unique index plus a non-disclosing error |
|
||||
@@ -0,0 +1,203 @@
|
||||
# Spec 4 — Admin Site
|
||||
|
||||
Date: 2026-07-24
|
||||
Status: Design approved, not implemented
|
||||
Depends on: spec 3 (`admin-backend`)
|
||||
Ships: with spec 3. Can be developed in parallel with spec 5 once spec 3's API
|
||||
is stable.
|
||||
|
||||
## Context
|
||||
|
||||
A fifth Next.js app, `adminsite/`, serving two audiences from one codebase:
|
||||
|
||||
- **Staff** — internal operators. Accounts, instances, licence history, plan
|
||||
editing, injection health, audit.
|
||||
- **Customers** — their own account, instances, licences and subscription state.
|
||||
|
||||
They share auth plumbing and a component library but almost no screens. The
|
||||
split is by route group, so a customer route can never accidentally render a
|
||||
staff view.
|
||||
|
||||
## Goals
|
||||
|
||||
1. A customer can buy, link a self-hosted instance, download a licence, and see
|
||||
when it expires — without contacting anyone.
|
||||
2. Staff can answer "why did this customer's instance stop working" in one screen.
|
||||
3. Nothing about the marketing site or the control-plane UI changes.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Rebuilding billing management. Card details, invoices, payment methods and
|
||||
cancellation all deep-link into Paddle's customer portal.
|
||||
- Server management. This is not a second control plane; there is exactly one
|
||||
link out to the instance and no data about servers, keys or workflows.
|
||||
- Public signup for cloud. That stays on the marketing site (moving to admin's
|
||||
backend in spec 5, but the *form* stays where customers already find it).
|
||||
|
||||
## Design
|
||||
|
||||
### App
|
||||
|
||||
Built exactly like `web/` and `site/`: Next.js 16 App Router, React 18, Tailwind
|
||||
3, TanStack Query, `output: "standalone"`, `node:26-alpine`, listening on `3000`,
|
||||
published as `3002`. In `docker-compose.site.yml` only.
|
||||
|
||||
`ADMIN_API_URL` is baked in at build time, as `API_URL` is for `web/`. It must be
|
||||
**browser-reachable** and must appear in the backend's `ADMIN_ORIGIN`. Getting
|
||||
this wrong is the single most common deployment failure in this repo's history —
|
||||
`SITE_API_URL` has the same footgun documented in `CLAUDE.md` — so the app
|
||||
renders an explicit "not connected" state rather than failing silently.
|
||||
|
||||
```
|
||||
adminsite/
|
||||
├── app/
|
||||
│ ├── login/
|
||||
│ ├── signup/ # self-hosted customer account creation
|
||||
│ ├── verify/
|
||||
│ ├── (customer)/
|
||||
│ │ ├── page.tsx # account overview
|
||||
│ │ ├── instances/[id]/
|
||||
│ │ ├── instances/link/
|
||||
│ │ ├── billing/
|
||||
│ │ └── layout.tsx # customer nav, account guard
|
||||
│ └── (staff)/staff/
|
||||
│ ├── page.tsx # operations dashboard
|
||||
│ ├── accounts/[id]/
|
||||
│ ├── instances/[id]/
|
||||
│ ├── licenses/
|
||||
│ ├── plans/
|
||||
│ └── layout.tsx # staff nav, staff guard
|
||||
├── components/
|
||||
└── lib/
|
||||
```
|
||||
|
||||
Route-group layouts do the guarding. A customer session hitting `/staff/*` gets
|
||||
redirected, not a 403 page — there is nothing to tell them about.
|
||||
|
||||
### Customer screens
|
||||
|
||||
**Overview** — the account, its instances as cards. Each card: name, cloud or
|
||||
self-hosted, tier, licence state, expiry with days remaining, and a link either
|
||||
to the instance's subdomain (cloud) or to its licence page (self-hosted).
|
||||
|
||||
Licence state is colour-coded and blunt: green valid, amber under 14 days, red
|
||||
expired. An expired card says what still works — "servers and monitors are still
|
||||
running; changes are disabled" — because that is the first thing a worried
|
||||
customer wants to know.
|
||||
|
||||
**Instance detail** — tier, limits, features, subscription status, next renewal
|
||||
date. For self-hosted: the linked UUID, a **Download licence** button, the blob
|
||||
in a copy-to-clipboard box, and step-by-step paste instructions with the target
|
||||
route named (`Settings → Licence` on their own install). A **Relink** action
|
||||
showing the remaining allowance ("2 of 3 relinks remaining this term").
|
||||
|
||||
**Link an instance** — the self-hosted activation screen. Explains where to find
|
||||
the UUID (shown on `/setup`, and permanently on `/settings/license`), takes the
|
||||
paste, validates the format client-side, and on success issues the licence and
|
||||
lands the customer directly on the download.
|
||||
|
||||
The whole flow — buy, link, download, paste — should be completable without
|
||||
reading documentation. That is the bar for this screen.
|
||||
|
||||
**Billing** — subscription list with status and renewal date, plus a button to
|
||||
Paddle's portal. Deliberately thin.
|
||||
|
||||
### Staff screens
|
||||
|
||||
**Dashboard** — the operational answers, not vanity metrics: licences expiring
|
||||
in the next 14 days, subscriptions `past_due`, instances `awaiting_link` for more
|
||||
than 48 hours, and **failed injections** from the reconciliation job. Each row
|
||||
links straight to the thing that needs doing.
|
||||
|
||||
**Accounts** — searchable by name, email, Paddle customer ID and instance UUID.
|
||||
Searching by UUID matters: a support email arrives containing a UUID and nothing
|
||||
else.
|
||||
|
||||
**Account detail** — instances, subscriptions, customer users, audit trail.
|
||||
|
||||
**Instance detail** — everything about one instance, with the **full licence
|
||||
history as a timeline**: issued, superseded, renewed, relinked, each with a
|
||||
timestamp, reason and who did it. This is the screen that answers "why did this
|
||||
stop working on the 14th". Actions: issue, reissue, relink without limit, and a
|
||||
live view of the control-plane injection state for cloud instances.
|
||||
|
||||
**Licences** — global history, filterable by tier, deployment, expiry window and
|
||||
issuance reason.
|
||||
|
||||
**Plans** — edit limits and features per tier. Two guard rails, because this
|
||||
screen changes what every future customer gets:
|
||||
|
||||
- A confirmation step naming exactly what changes and stating that existing
|
||||
licences are unaffected until reissued.
|
||||
- The deployment field is not editable. Moving Free to `self_hosted` would break
|
||||
the cloud-only rule that spec 1 leans on; changing it is a code review, not a
|
||||
form field.
|
||||
|
||||
**Audit** — every mutating action, filterable.
|
||||
|
||||
### Design language
|
||||
|
||||
Visually distinct from `web/`. Staff regularly have both open, and a moment of
|
||||
"which app am I in" before clicking Reissue is worth designing out. Different
|
||||
accent colour and a persistent environment badge in the header (sandbox or
|
||||
production, from a build-time flag) — clicking Issue against the wrong Paddle
|
||||
environment should be hard.
|
||||
|
||||
Shared component patterns with `web/` where they exist; this is not a reason to
|
||||
invent a second design system.
|
||||
|
||||
### Error and empty states
|
||||
|
||||
- Backend unreachable: a page-level "not connected" state naming
|
||||
`ADMIN_API_URL`, matching the pattern the marketing site already uses.
|
||||
- No instances yet: a customer-facing explanation of the two paths — buy cloud,
|
||||
or buy self-hosted and link.
|
||||
- `awaiting_link`: a prominent prompt on the overview, since a customer who has
|
||||
paid and not linked is a customer who has paid for nothing yet.
|
||||
- Licence download failure: show the blob inline as a fallback so the customer is
|
||||
never blocked by a file download.
|
||||
|
||||
## Testing
|
||||
|
||||
Component and integration tests with mocked API responses. The repo has no
|
||||
frontend test setup today; this is where one starts, scoped to the flows that
|
||||
lose money or leak data when broken.
|
||||
|
||||
1. Customer session on `/staff/*` redirects; staff session reaches it.
|
||||
2. Instance card renders correctly for each licence state, including expired,
|
||||
and the expired copy names what still works.
|
||||
3. Link flow: valid UUID succeeds and lands on download; malformed UUID is caught
|
||||
client-side; already-linked UUID surfaces the backend's message.
|
||||
4. Relink shows the remaining allowance and disables at zero with the support
|
||||
message.
|
||||
5. Licence download failure falls back to the inline blob.
|
||||
6. Not-connected state renders when the API is unreachable.
|
||||
7. Staff dashboard renders each alert category and links to the right resource.
|
||||
8. Plan edit requires confirmation and shows the "existing licences unaffected"
|
||||
wording.
|
||||
9. Instance search by UUID returns the instance.
|
||||
10. Licence history timeline renders every reason type in order.
|
||||
|
||||
## Verification before merge
|
||||
|
||||
1. Test suite green.
|
||||
2. Full manual pass, self-hosted purchase to working licence, using only the UI
|
||||
and no documentation — timed, and if it takes more than five minutes the flow
|
||||
needs work.
|
||||
3. Full manual pass, cloud: buy, confirm the licence appears in the control plane
|
||||
within a minute, confirm the instance's own settings page agrees.
|
||||
4. Staff pass: find an account by instance UUID, read its licence history,
|
||||
reissue, confirm the control plane picks it up.
|
||||
5. Responsive check at mobile width — a customer hit by an expiry email will open
|
||||
this on a phone.
|
||||
6. `docker build` from the repo root succeeds and the image runs with
|
||||
`ADMIN_API_URL` baked in.
|
||||
|
||||
## Risks
|
||||
|
||||
| Risk | Mitigation |
|
||||
|---|---|
|
||||
| `ADMIN_API_URL` misconfigured at build | Explicit not-connected state; documented alongside the existing `SITE_API_URL` footgun |
|
||||
| Staff action taken against the wrong environment | Persistent environment badge; confirmation on destructive actions |
|
||||
| Customer confused by the self-hosted flow | Step-by-step link screen; five-minute bar in verification |
|
||||
| Customer session reaching staff data | Route-group guards plus backend scoping (spec 3, test 20). Two layers, because one is not enough for this |
|
||||
@@ -0,0 +1,353 @@
|
||||
# Spec 2 — Instance Licensing and Enforcement
|
||||
|
||||
Date: 2026-07-24
|
||||
Status: Design approved, not implemented
|
||||
Depends on: spec 0a, spec 0b, spec 1 (`licensing-core`)
|
||||
Ships: independently, with licenses issued by hand via `lkctl`. No admin site
|
||||
needed.
|
||||
|
||||
## Context
|
||||
|
||||
Spec 1 defines what a license is. This spec makes the control plane hold one,
|
||||
act on it, and let a self-hosted operator paste one in.
|
||||
|
||||
The guiding rule: **an expired license must never break a running fleet.** Agents
|
||||
keep their keys, monitors keep watching, alerts keep firing. What stops is
|
||||
growth and change. A customer whose card fails should be inconvenienced, not
|
||||
paged at 3am because their monitoring went dark when Vantage decided to sulk.
|
||||
|
||||
## Goals
|
||||
|
||||
1. A license lives on the instance document and is verified on read.
|
||||
2. Enforcement is deny-by-default: a new mutating route is gated because of where
|
||||
it is mounted, not because someone remembered.
|
||||
3. Degraded mode is obvious in the UI and reversible by pasting a valid license.
|
||||
4. Self-hosted operators get an instance UUID they can hand to the admin site.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Issuing licenses. `lkctl` (spec 1) or the admin backend (spec 3).
|
||||
- Any outbound network call. Verification is offline, permanently.
|
||||
- Per-user or per-role licensing. The unit is the instance.
|
||||
|
||||
## Design
|
||||
|
||||
### Instance identity
|
||||
|
||||
Every install already has an `Instance` document with an `InstanceID` UUID. For
|
||||
cloud instances this is created by signup; for self-hosted it is created by
|
||||
`/setup`.
|
||||
|
||||
Change to `/setup`: after bootstrapping the first instance and its owner, the
|
||||
setup page **displays the instance UUID** with a copy button and the text that
|
||||
it is needed to activate a license. It is also shown permanently on
|
||||
`/settings/license`.
|
||||
|
||||
No new identifier is invented. The instance UUID is the licensing identity.
|
||||
|
||||
### Storage
|
||||
|
||||
`shared/models.Instance` gains:
|
||||
|
||||
```go
|
||||
LicenseBlob string `bson:"license_blob,omitempty" json:"-"`
|
||||
LicenseTier string `bson:"license_tier,omitempty" json:"license_tier,omitempty"`
|
||||
LicenseExpiry *time.Time `bson:"license_expiry,omitempty" json:"license_expiry,omitempty"`
|
||||
```
|
||||
|
||||
The blob is authoritative. `LicenseTier` and `LicenseExpiry` are a denormalised
|
||||
cache for listing and for the admin site's queries, rewritten from the verified
|
||||
payload every time a blob is accepted. Nothing reads them for enforcement.
|
||||
|
||||
`LicenseBlob` is `json:"-"`. It is not a secret in the confidentiality sense —
|
||||
it is signed public data — but there is no reason to spray it through API
|
||||
responses.
|
||||
|
||||
### Runtime state
|
||||
|
||||
```go
|
||||
type State struct {
|
||||
Status license.State // valid | expired | invalid
|
||||
Reason string
|
||||
Tier string
|
||||
ExpiresAt *time.Time
|
||||
Limits license.Limits
|
||||
Features map[string]bool
|
||||
}
|
||||
```
|
||||
|
||||
Resolved by `services.LicenseState(instanceID) State`, cached for 60 seconds
|
||||
alongside the existing instance cache and invalidated immediately when a blob is
|
||||
stored.
|
||||
|
||||
Three inputs, in precedence order:
|
||||
|
||||
1. `Instance.LicenseBlob`.
|
||||
2. `VANTAGE_LICENSE` environment variable, used **only when the instance has no
|
||||
stored blob**. This lets an automated self-hosted deployment ship a license
|
||||
without a human pasting one. A blob stored through the UI always wins
|
||||
afterwards, so an operator is never locked out by a stale environment value.
|
||||
3. Neither → `Status: invalid`, `Reason: no_license`.
|
||||
|
||||
The verifier is called with `InstanceID` from the instance document and
|
||||
`Deployment` from `VANTAGE_DEPLOYMENT` (`cloud` on our infrastructure,
|
||||
`self_hosted` everywhere else, defaulting to `self_hosted`). The default matters:
|
||||
an operator who removes the variable gets the stricter mode, not the looser one.
|
||||
|
||||
`invalid` and `expired` degrade identically. They differ only in the message.
|
||||
|
||||
### Enforcement
|
||||
|
||||
Three layers, deliberately separate because they answer different questions.
|
||||
|
||||
**Layer 1 — mutation gate.** A gin middleware `RequireActiveLicense` mounted on
|
||||
the `/api` group, applying to every request whose method is not `GET` or `HEAD`.
|
||||
|
||||
```go
|
||||
api := r.Group("/api", auth.RequireSession(), services.RequireActiveLicense())
|
||||
```
|
||||
|
||||
Non-`valid` → `403 {"error":"license_required","state":"expired","reason":"..."}`.
|
||||
|
||||
Mounting at the group means **a route added tomorrow is gated by default**. That
|
||||
is the whole point of putting it here rather than on individual handlers.
|
||||
|
||||
Explicit exemptions, allow-listed by path because they must work in degraded
|
||||
mode:
|
||||
|
||||
| Route | Why |
|
||||
|---|---|
|
||||
| `POST /api/license` | Pasting a valid license is how you recover |
|
||||
| `POST /auth/*` | Login and logout are outside `/api` already; listed for clarity |
|
||||
| `DELETE` on any resource | Deleting is how you get back under a limit |
|
||||
| `POST /api/servers/:id/apply-updates` | Security patching must never be paywalled |
|
||||
|
||||
The `DELETE` exemption deserves emphasis: a customer downgraded to Free with 10
|
||||
servers must be able to remove 7 of them. Blocking deletes would trap them.
|
||||
|
||||
**Layer 2 — feature gate.** `RequireFeature(name)` on the route groups that need
|
||||
it:
|
||||
|
||||
- `console` → `POST /api/console/connect`, `GET /api/console/tunnel`
|
||||
- `oidc` → `GET,PUT /api/instance/oidc`
|
||||
|
||||
Missing feature → `403 {"error":"feature_unavailable","feature":"console"}`.
|
||||
|
||||
OIDC needs care: `/auth/oidc/start` and `/auth/oidc/callback` are unauthenticated
|
||||
and outside `/api`. They check the feature directly and, if unavailable, redirect
|
||||
to `/login?error=oidc_unavailable` rather than returning JSON. **Existing OIDC
|
||||
sessions are not terminated** — losing the feature stops new SSO logins, it does
|
||||
not evict people mid-session.
|
||||
|
||||
**Layer 3 — limits.** Enforced in the service layer, because a limit needs a
|
||||
count that middleware does not have:
|
||||
|
||||
| Limit | Checked in |
|
||||
|---|---|
|
||||
| `max_servers` | `services.CreateServer` / `POST /api/servers/new` |
|
||||
| `max_secret_groups` | `services.CreateSecretGroup` |
|
||||
| `max_channels` | `services.CreateChannel` |
|
||||
|
||||
`-1` means unlimited. Over limit → `403 {"error":"limit_exceeded","limit":"max_servers","current":3,"max":3}`.
|
||||
|
||||
Counts are of live rows: revoked assignments and deleted servers do not count.
|
||||
|
||||
**Over-limit instances are never truncated.** A Professional instance with 20
|
||||
servers that lapses to Free keeps all 20 running; it simply cannot add a 21st.
|
||||
Deleting resources is always permitted. Silently disabling a customer's servers
|
||||
because their card expired is not a behaviour this system will have.
|
||||
|
||||
### Background work in degraded mode
|
||||
|
||||
This is where "read-only" needs to be specific, because these paths do not go
|
||||
through gin at all.
|
||||
|
||||
| Subsystem | Degraded behaviour |
|
||||
|---|---|
|
||||
| **Monitor scheduler** | **Keeps running.** Checks execute, incidents open, notifications fire. |
|
||||
| Monitor create/edit/delete | Blocked by layer 1 (delete exempted). |
|
||||
| Workflow runner | New runs blocked by layer 1. **In-flight runs finish** rather than being killed mid-step — a half-run workflow is worse than a completed one. |
|
||||
| Agent `SyncKeys` | Returns the existing desired key set unchanged. Nothing is torn off disk. New assignments cannot be created, so nothing changes anyway. |
|
||||
| Agent registration | A **new** agent registering against an over-limit instance is refused with a clear message; existing agents re-register freely. |
|
||||
| Inventory, heartbeat, update reporting | Unaffected. |
|
||||
| `ApplyUpdatesCmd` | Allowed. Security patching is not gated. |
|
||||
| ESO secrets read (`GET /api/secrets/:group/values`) | **Allowed.** It is a `GET`, and breaking a Kubernetes cluster's secret sync over a billing state is disproportionate. |
|
||||
| Log retention sweep, offline sweep | Unaffected. |
|
||||
|
||||
Keeping monitors alive is a deliberate reversal of a stricter earlier draft. It
|
||||
is the single most important line in this spec: **billing state must not take
|
||||
away a customer's ability to know their infrastructure is on fire.**
|
||||
|
||||
### API
|
||||
|
||||
```
|
||||
GET /api/license any authenticated user
|
||||
POST /api/license owner only
|
||||
```
|
||||
|
||||
`GET` returns:
|
||||
|
||||
```json
|
||||
{
|
||||
"instance_id": "…",
|
||||
"state": "valid",
|
||||
"reason": "",
|
||||
"tier": "professional",
|
||||
"expires_at": "2027-07-24T00:00:00Z",
|
||||
"days_remaining": 365,
|
||||
"limits": { "max_servers": -1, "max_secret_groups": -1, "max_channels": -1 },
|
||||
"features": { "console": true, "oidc": true },
|
||||
"usage": { "servers": 12, "secret_groups": 4, "channels": 2 },
|
||||
"source": "stored"
|
||||
}
|
||||
```
|
||||
|
||||
`usage` is included so the UI can render "12 of 3 servers" honestly when an
|
||||
instance is over its limit, rather than pretending.
|
||||
|
||||
`POST` takes `{"blob": "..."}`, verifies with the instance's own ID and
|
||||
deployment mode, and on success stores the blob, refreshes the cache, and writes
|
||||
an audit event. On failure it returns `400` with the specific reason:
|
||||
|
||||
| Reason | Message |
|
||||
|---|---|
|
||||
| `bad_signature` | This licence key is not valid. Check it was copied in full. |
|
||||
| `deployment_mismatch` | This licence is for Vantage Cloud and cannot be used on a self-hosted install. |
|
||||
| `instance_mismatch` | This licence was issued for a different instance. Your instance ID is `<uuid>`. |
|
||||
| `expired` | This licence expired on `<date>`. |
|
||||
|
||||
An **expired** blob is still stored if it is otherwise valid, so the UI can show
|
||||
what expired and when. An **invalid** blob is rejected and the previous one kept.
|
||||
|
||||
Rate-limited to 10 attempts per instance per hour. There is no oracle here worth
|
||||
protecting, but an unbounded verify endpoint is an unbounded CPU endpoint.
|
||||
|
||||
### Frontend
|
||||
|
||||
`useLicense()` hook over `GET /api/license`, cached by TanStack Query and
|
||||
invalidated after a successful paste.
|
||||
|
||||
- **Banner, persistent, top of every page** when `state != valid`:
|
||||
- `expired` — "Your Vantage licence expired on `<date>`. Your servers and
|
||||
monitors are still running, but changes are disabled until it is renewed."
|
||||
with a link to the admin site.
|
||||
- `invalid` / `no_license` — "This instance has no valid licence. Add one in
|
||||
Settings → Licence."
|
||||
- **Warning banner** in the final 14 days of a valid term, dismissible per
|
||||
session.
|
||||
- **Gated features render disabled with an upgrade tooltip, not hidden.** A
|
||||
customer cannot buy what they cannot see, and a feature that vanishes reads as
|
||||
a bug.
|
||||
- **Limit indicators** on the servers, secrets and channels list pages: "3 of 3
|
||||
servers used" with the create button disabled at the cap.
|
||||
- `/settings/license`: current state, tier, expiry, limits with live usage, the
|
||||
instance UUID with a copy button, and a textarea plus file upload for a new
|
||||
blob. Owner-only; other roles see the state read-only.
|
||||
|
||||
### Grandfathering existing tenants
|
||||
|
||||
Migration `0005_grandfather_licenses`, cloud only, guarded on
|
||||
`VANTAGE_DEPLOYMENT == "cloud"`:
|
||||
|
||||
For every instance with no `license_blob`, issue a Professional license expiring
|
||||
**one year** from the migration date and store it.
|
||||
|
||||
The migration cannot sign — the server has no private key and, per spec 1, no
|
||||
signing code. So the blobs are **generated ahead of time with `lkctl`** and
|
||||
supplied to the migration through `VANTAGE_GRANDFATHER_BLOBS`, a JSON map of
|
||||
instance ID to blob. The migration stores what it is given, verifies each blob
|
||||
against its instance before storing, and logs any instance it had no blob for.
|
||||
|
||||
Clumsy, and correct. The alternative is putting a signing key in the control
|
||||
plane, which is the thing this design most wants to avoid.
|
||||
|
||||
Self-hosted installs are not grandfathered. On upgrade they land in `no_license`
|
||||
and read-only until an operator pastes a key — which is the intended behaviour
|
||||
for a paid product, and is why the release notes must lead with it.
|
||||
|
||||
## Testing
|
||||
|
||||
**Unit, no database:**
|
||||
|
||||
1. `State` resolution precedence: stored blob wins over `VANTAGE_LICENSE`;
|
||||
environment used when no blob; neither → `no_license`.
|
||||
2. Feature map construction from the payload's `Features` slice.
|
||||
3. Limit comparison with `-1`, with zero, and with a count exactly at the cap.
|
||||
|
||||
**Middleware, with a stub state:**
|
||||
|
||||
4. `GET` passes in every state.
|
||||
5. `POST`/`PUT`/`DELETE` pass when `valid`, fail `403` when `expired` and when
|
||||
`invalid` — except `DELETE`, which passes in all states.
|
||||
6. `POST /api/license` passes when `expired` (the recovery path).
|
||||
7. `POST /api/servers/:id/apply-updates` passes when `expired`.
|
||||
8. `RequireFeature("console")` passes with the feature, `403`s without it.
|
||||
9. **Coverage test:** enumerate every registered route and assert that every
|
||||
non-`GET` route is either behind `RequireActiveLicense` or on the exemption
|
||||
allow-list. This test is what stops layer 1 rotting as routes are added.
|
||||
|
||||
**Service layer, against MongoDB:**
|
||||
|
||||
10. `CreateServer` at the cap → `limit_exceeded`; one below → succeeds.
|
||||
11. Over-limit instance can still `DELETE` a server, and can create again once
|
||||
back under the cap.
|
||||
12. Deleted and revoked rows do not count toward limits.
|
||||
|
||||
**Degraded background behaviour:**
|
||||
|
||||
13. Monitor scheduler executes checks for an instance with an expired license.
|
||||
14. An incident opened during degraded mode still dispatches notifications.
|
||||
15. `SyncKeys` for an expired instance returns the same key set as before expiry.
|
||||
16. A new agent registering against an over-limit instance is refused; an
|
||||
existing agent re-registers successfully.
|
||||
17. A workflow run in flight when the license expires completes its remaining
|
||||
steps.
|
||||
|
||||
**API:**
|
||||
|
||||
18. `POST /api/license` with a valid blob stores it and flips state to `valid`.
|
||||
19. Each rejection reason returns its own message and leaves the stored blob
|
||||
untouched.
|
||||
20. An expired-but-well-formed blob is stored and reported as `expired`.
|
||||
21. Non-owner `POST` → `403`.
|
||||
|
||||
**Migration:**
|
||||
|
||||
22. `0005` stores and verifies supplied blobs, skips instances that already have
|
||||
one, logs instances with no blob supplied, and is a no-op when
|
||||
`VANTAGE_DEPLOYMENT != "cloud"`.
|
||||
|
||||
## Verification before merge
|
||||
|
||||
1. Full test suite green, including the route-coverage test (test 9).
|
||||
2. Manual pass on a scratch instance: issue a Professional license with `lkctl`,
|
||||
paste it, confirm everything works. Issue one expiring in 60 seconds, wait,
|
||||
confirm the banner appears, mutations `403`, **monitors keep firing**, and
|
||||
pasting a fresh license restores normal operation without a restart.
|
||||
3. Manual pass on the Free tier: confirm the 3-server cap, that console and OIDC
|
||||
are visibly disabled with upgrade tooltips, and that a 4th server is refused
|
||||
with a clear message.
|
||||
4. Confirm a cloud-issued Free license is rejected on a `self_hosted` install
|
||||
with `deployment_mismatch`.
|
||||
5. Confirm a license issued for another instance is rejected with
|
||||
`instance_mismatch` and the message shows the correct local UUID.
|
||||
|
||||
## Rollout
|
||||
|
||||
1. Generate grandfather blobs with `lkctl` for every existing cloud instance.
|
||||
2. Deploy with `VANTAGE_GRANDFATHER_BLOBS` set; migration `0005` runs.
|
||||
3. Verify every cloud instance reports `valid`, Professional, one year out.
|
||||
4. Unset the variable on the next deploy — it is single-use.
|
||||
5. Release notes for self-hosted must state plainly that upgrading requires a
|
||||
licence key, and how to get one.
|
||||
|
||||
## Risks
|
||||
|
||||
| Risk | Mitigation |
|
||||
|---|---|
|
||||
| A mutating route added later without a gate | Route-coverage test (test 9) fails the build |
|
||||
| Customer locked out and unable to recover | `POST /api/license` and all `DELETE`s exempt from the gate |
|
||||
| Existing cloud tenants degrade on deploy | Migration 0005, verified before the traffic switch |
|
||||
| Over-limit customer trapped | Deletes always allowed; existing resources never truncated |
|
||||
| Clock wrong on a self-hosted host | `Verify` warns on a future `IssuedAt`; documented in the licence settings page |
|
||||
| Monitoring lost on billing failure | Explicitly designed out — the scheduler ignores licence state |
|
||||
@@ -0,0 +1,240 @@
|
||||
# Spec 0b — Org to Instance Rename
|
||||
|
||||
Date: 2026-07-24
|
||||
Status: Design approved, not implemented
|
||||
Depends on: spec 0a (`shared-module`)
|
||||
Ships: independently, before any licensing code
|
||||
|
||||
## Context
|
||||
|
||||
The licensing model separates two concepts that the codebase currently conflates
|
||||
under one word:
|
||||
|
||||
- **Account** — a paying customer. Lives only in the admin control plane
|
||||
(spec 3). The control plane never learns about it.
|
||||
- **Instance** — one deployment of Vantage: its own subdomain, its own users,
|
||||
its own servers, keys, workflows, monitors and secrets. One license attaches
|
||||
to one instance.
|
||||
|
||||
Today's control-plane `Org` **is** an Instance. An Account may hold several,
|
||||
some cloud and some self-hosted, and the self-hosted ones have no row in the
|
||||
cloud database at all.
|
||||
|
||||
Keeping the name `Org` would leave the control plane using a word that means
|
||||
something different in the admin site, in Paddle, and in every support
|
||||
conversation. This spec renames it everywhere, including on disk.
|
||||
|
||||
This is the highest-risk change in the programme: `org_id` is the tenant
|
||||
isolation key on every document in every collection. It is done alone, before
|
||||
anything else, so that nothing else is in flight when it deploys.
|
||||
|
||||
## Goals
|
||||
|
||||
1. `Instance` is the only word for a tenant, in code, API, UI and database.
|
||||
2. No document is lost and no tenant scoping is weakened.
|
||||
3. The migration is reversible.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Any behaviour change. Same routes' semantics, same permissions, same data.
|
||||
- Introducing Accounts. The control plane never gets them.
|
||||
- Touching the agent. It talks gRPC and has no concept of a tenant.
|
||||
|
||||
## Design
|
||||
|
||||
### Naming map
|
||||
|
||||
| Today | After |
|
||||
|---|---|
|
||||
| collection `orgs` | `instances` |
|
||||
| collection `org_oidc` | `instance_oidc` |
|
||||
| field `org_id` (all collections) | `instance_id` |
|
||||
| `models.Org` | `models.Instance` |
|
||||
| `Org.OrgID` | `Instance.InstanceID` |
|
||||
| `User.OrgID`, `Settings.OrgID`, every `OrgID` field | `InstanceID` |
|
||||
| `services/orgs.go`, `GetOrg`, `CreateOrg`, `ListOrgIDs`, `CountOrgs`, `FirstOrg`, `AdoptOrg`, `GetOrgBySlug` | `services/instances.go`, `GetInstance`, `CreateInstance`, … |
|
||||
| `services/org_oidc.go` | `services/instance_oidc.go` |
|
||||
| `auth/orghost.go` | `auth/instancehost.go` |
|
||||
| `/api/org/users`, `/api/org/oidc` | `/api/instance/users`, `/api/instance/oidc` |
|
||||
| `shared/provision.CreateOrg`, `RollbackOrg` | `CreateInstance`, `RollbackInstance` |
|
||||
| session field `org_id` | `instance_id` |
|
||||
| `GET /auth/me` response `org_id` / `org` | `instance_id` / `instance` |
|
||||
| UI copy "Organisation" | "Instance" |
|
||||
|
||||
Reserved slugs gain no new entries here, but note `admin` is already reserved,
|
||||
which the admin site relies on later.
|
||||
|
||||
### Collections carrying `org_id`
|
||||
|
||||
All of: `servers`, `keys`, `assignments`, `users`, `org_oidc`, `settings`,
|
||||
`secrets`, `workflows`, `workflow_steps`, `workflow_runs`, `monitors`,
|
||||
`incidents`, `monitor_rollups`, `notification_channels`, `console_sessions`,
|
||||
`audit_logs`, plus `orgs` itself. `migrations` does not carry one.
|
||||
|
||||
`site_pending_signups` does not carry `org_id`, but its `org_name` field becomes
|
||||
`instance_name` for consistency; it is sitesvc-private so this is free.
|
||||
|
||||
The migration must derive this list from a constant in code, not from a
|
||||
hand-written list in a runbook, so that a collection added between design and
|
||||
deploy is not silently missed:
|
||||
|
||||
```go
|
||||
var scopedCollections = []string{ /* the list above */ }
|
||||
```
|
||||
|
||||
A boot-time assertion (spec 2 onwards) checks that no collection outside this
|
||||
list contains an `org_id` field. Cheap insurance against a future collection
|
||||
being added without being renamed.
|
||||
|
||||
### Migration `0004_org_to_instance`
|
||||
|
||||
Recorded in `migrations` like the existing three. Runs after
|
||||
`0003_missed_org_scopes`.
|
||||
|
||||
**The migration only renames. It never deletes and never drops.** A bad deploy
|
||||
is recovered by running the inverse rename, not by restoring a backup.
|
||||
|
||||
Steps, in order:
|
||||
|
||||
1. **Guard.** If collection `instances` already exists and `orgs` does not, the
|
||||
migration has already run against this database by an earlier binary; record
|
||||
the marker and return. Idempotency matters because the marker write and the
|
||||
data work are not in one transaction.
|
||||
2. **Rename collections.** `orgs` → `instances`, `org_oidc` → `instance_oidc`,
|
||||
via `adminCommand{renameCollection}`. Fails loudly if the target exists.
|
||||
3. **Rename the field.** For each collection in `scopedCollections`:
|
||||
`UpdateMany({org_id: {$exists: true}}, {$rename: {"org_id": "instance_id"}})`.
|
||||
Record `matched` and `modified` per collection in the log.
|
||||
4. **Verify.** For each collection, assert
|
||||
`CountDocuments({org_id: {$exists: true}}) == 0` and
|
||||
`CountDocuments({instance_id: {$exists: true}}) == totalCount`. Any mismatch
|
||||
aborts before the marker is written, leaving the migration to retry.
|
||||
5. **Indexes.** Drop and recreate indexes that name `org_id` in their key spec:
|
||||
unique `settings.instance_id`, the ESO token-hash index, and any compound
|
||||
scoping indexes. Unique `instances.slug` and `users.email` are unaffected by
|
||||
the field rename but are re-declared idempotently.
|
||||
6. **Write the marker.**
|
||||
|
||||
Steps 2–4 are not atomic across collections. Mongo multi-document transactions
|
||||
would require a replica set, which is not guaranteed for self-hosted installs.
|
||||
Instead the migration is written to be **safely re-runnable**: `$rename` on a
|
||||
document that has already been renamed matches nothing, and the collection
|
||||
rename is guarded in step 1.
|
||||
|
||||
Rollback, if ever needed, is the same code with the rename reversed, shipped as
|
||||
a one-shot command rather than a migration — deliberately manual, because the
|
||||
only reason to run it is a decision to revert the release.
|
||||
|
||||
### Version skew
|
||||
|
||||
`sitesvc` and `server` write the same documents. A skew where one writes
|
||||
`org_id` and the other reads `instance_id` creates tenants that are invisible to
|
||||
the application — the exact failure `CLAUDE.md` warns about.
|
||||
|
||||
After spec 0a both read the shape from `shared`, so the skew window is a
|
||||
deployment-ordering problem rather than a code-drift problem:
|
||||
|
||||
- Both images are built from the same commit and deployed together.
|
||||
- The migration runs from the `server` container at boot, as the existing three
|
||||
do.
|
||||
- `sitesvc` at boot asserts that collection `instances` exists and refuses to
|
||||
start otherwise, with the message
|
||||
`instances collection not found; deploy the control plane first`. Failing to
|
||||
start is strictly better than provisioning into a collection nobody reads.
|
||||
|
||||
The self-hosted deployment runs no sitesvc, so it sees only the server change.
|
||||
|
||||
### API and frontend
|
||||
|
||||
REST route renames are **breaking**, but every consumer is first-party (`web/`)
|
||||
and ships in the same release. No compatibility aliases — a permanent dual path
|
||||
in the tenant-scoping layer is worse than a coordinated release.
|
||||
|
||||
`web/` changes: the API client's paths, the `useMe` shape, all UI copy from
|
||||
"Organisation" to "Instance", and the settings route `/settings/org` →
|
||||
`/settings/instance`.
|
||||
|
||||
`site/` marketing copy changes where it says "organisation" about a tenant. Where
|
||||
it means the customer, it becomes "account" — that word now has a specific
|
||||
meaning and the marketing site is the first place a customer meets it.
|
||||
|
||||
## Testing
|
||||
|
||||
**No automated tests.** Decision taken 2026-07-24, consistent with spec 0a.
|
||||
|
||||
This is the change where that costs the most: it moves the tenant isolation key
|
||||
across 17 collections, and a mistake orphans a customer's entire fleet rather
|
||||
than breaking a build. The compensating controls are therefore not optional, and
|
||||
the implementation plan makes each a mandatory step:
|
||||
|
||||
1. **Dry run against a restored copy** before the code is even committed —
|
||||
migrate a `mongorestore`d duplicate of production and read the per-collection
|
||||
rename counts.
|
||||
2. **Idempotency by hand** — run the dry run twice; the second must complete
|
||||
with no error and nothing left to rename.
|
||||
3. **Interrupted-run recovery by hand** — rename `orgs` manually, then run the
|
||||
migration; it must complete and leave every document carrying `instance_id`.
|
||||
4. **Count comparison against a production snapshot** — record every
|
||||
collection's document count before and after; any difference stops the
|
||||
release.
|
||||
5. **Per-tenant isolation comparison** — for three real tenants, count rows in
|
||||
`servers`, `keys`, `workflows`, `monitors`, `secrets` and `audit_logs` by
|
||||
`org_id` before and by `instance_id` after. Identical, or the release stops.
|
||||
This is the check that proves tenant isolation survived.
|
||||
6. **Stale-field sweep** — assert no collection anywhere still holds an
|
||||
`org_id`.
|
||||
7. **Rollback rehearsal** — migrate a third copy, run `rename-rollback`, confirm
|
||||
the counts return to baseline and the pre-release binary boots against it.
|
||||
Deploying without having done this is not permitted.
|
||||
8. **Boot guard, both directions** — sitesvc must refuse an unmigrated database
|
||||
and start normally against a migrated one.
|
||||
|
||||
`AssertNoScopedCollectionMissed` runs at every boot and is fatal. With no test
|
||||
suite it is the standing protection against a future collection being added
|
||||
without being added to `ScopedCollections`.
|
||||
|
||||
## Verification before merge
|
||||
|
||||
Run against a **restored production snapshot**, not a synthetic database:
|
||||
|
||||
1. Record `db.getCollectionNames()` and per-collection `countDocuments()` before.
|
||||
2. Run the migration.
|
||||
3. Assert every count is identical afterwards.
|
||||
4. Assert `instances.countDocuments()` equals the old `orgs.countDocuments()`.
|
||||
5. Pick three real tenants; run the same scoped query before (by `org_id`) and
|
||||
after (by `instance_id`) and confirm identical result sets. This is the test
|
||||
that proves tenant isolation survived.
|
||||
6. Boot the server against the migrated snapshot; log in as a real user; confirm
|
||||
servers, keys, workflows, monitors and secrets all list correctly.
|
||||
7. Boot sitesvc against the migrated snapshot; complete a signup end to end.
|
||||
8. Boot sitesvc against an **un**migrated snapshot; confirm it refuses to start
|
||||
with the expected message.
|
||||
|
||||
## Rollout
|
||||
|
||||
1. Take a database backup. Not optional — this is the one change where the
|
||||
inverse rename is the recovery path and the backup is the second.
|
||||
2. Deploy `server`, `web`, `site` and `sitesvc` from one commit, together.
|
||||
3. Server boots, migration runs, marker recorded.
|
||||
4. Watch for the sitesvc guard message; if it appears, sitesvc started first and
|
||||
will restart cleanly.
|
||||
|
||||
Expect a short window during the server restart where the API is unavailable.
|
||||
Agents are unaffected: they reconnect, and no gRPC message carries a tenant ID.
|
||||
|
||||
## Risks
|
||||
|
||||
| Risk | Mitigation |
|
||||
|---|---|
|
||||
| Partial migration leaves mixed field names | Step 4 verification aborts before the marker; migration is re-runnable |
|
||||
| A collection missed from the list | List is a code constant plus a completeness test plus a boot-time assertion |
|
||||
| sitesvc deployed before server | Boot guard refuses to start |
|
||||
| An index still keyed on `org_id` | Step 5 drops and recreates; verification includes an index listing diff |
|
||||
| A hard-coded `org_id` string outside the model layer | `grep -rn '"org_id"' server/ sitesvc/ shared/` must return only the migration file after the change |
|
||||
| Frontend missed a renamed route | Full manual pass over every route in the UI before release |
|
||||
|
||||
## Follow-on
|
||||
|
||||
With `Instance` established, spec 1 (`licensing-core`) can define a license
|
||||
payload that binds to `instance_id` without inventing a word the codebase does
|
||||
not use.
|
||||
@@ -0,0 +1,294 @@
|
||||
# Spec 1 — Licensing Core
|
||||
|
||||
Date: 2026-07-24
|
||||
Status: Design approved, not implemented
|
||||
Depends on: spec 0a (`shared-module`), spec 0b (`instance-rename`)
|
||||
Ships: independently. Adds a package and a CLI; changes no running behaviour.
|
||||
|
||||
## Context
|
||||
|
||||
Licenses are **offline-verified signed blobs**. A Vantage server checks a
|
||||
signature and an expiry date and asks nobody's permission. That choice buys
|
||||
self-hosted installs that work in air-gapped networks and a control plane with no
|
||||
licensing availability dependency.
|
||||
|
||||
It costs revocation. Once issued, a license is valid until it expires, whatever
|
||||
Paddle later says. Every other decision in the programme follows from accepting
|
||||
that: Self Hosted is annual-only so the unenforceable window is bounded, and
|
||||
cancellation takes effect at term end rather than immediately (spec 5).
|
||||
|
||||
This spec defines the payload, the signing and verification, and a CLI to issue
|
||||
licenses by hand. It deliberately lands before the admin site so that specs 1+2
|
||||
together give working licensing with no new service to operate.
|
||||
|
||||
## Goals
|
||||
|
||||
1. One struct, in `shared`, read identically by the verifier and the issuer.
|
||||
2. Verification that needs no network, no clock sync beyond a rough one, and no
|
||||
configuration.
|
||||
3. A hand-issuance path good enough to run production on until spec 3 lands.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Storing licenses. Spec 2 owns the instance document; spec 3 owns issuance
|
||||
history.
|
||||
- Deciding tier contents. Tiers are data; the values in this spec are the
|
||||
initial seed, and spec 3's `plans` table becomes their home.
|
||||
- Any phone-home, revocation list or online check. There is none, anywhere, by
|
||||
design.
|
||||
|
||||
## Design
|
||||
|
||||
### Package
|
||||
|
||||
`shared/license/`, inside the module created by spec 0a:
|
||||
|
||||
```
|
||||
shared/license/
|
||||
├── license.go # License, Limits, feature constants
|
||||
├── sign.go # Sign, build-tagged out of the server binary
|
||||
├── verify.go # Verify, Parse
|
||||
├── keys.go # trustedPublicKeys
|
||||
└── license_test.go
|
||||
```
|
||||
|
||||
Uses `github.com/hyperboloide/lk` (ed25519, base32 encoding).
|
||||
|
||||
### Payload
|
||||
|
||||
```go
|
||||
package license
|
||||
|
||||
type License struct {
|
||||
ID string `json:"id"` // uuid, for support and audit
|
||||
InstanceID string `json:"instance_id"` // the instance this license is bound to
|
||||
AccountID string `json:"account_id"` // admin-side customer, informational
|
||||
InstanceName string `json:"instance_name"` // display only
|
||||
Tier string `json:"tier"` // "free" | "professional" | "self_hosted"
|
||||
Deployment string `json:"deployment"` // "cloud" | "self_hosted"
|
||||
IssuedAt time.Time `json:"issued_at"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
Limits Limits `json:"limits"`
|
||||
Features []string `json:"features"`
|
||||
}
|
||||
|
||||
type Limits struct {
|
||||
MaxServers int `json:"max_servers"` // -1 means unlimited
|
||||
MaxSecretGroups int `json:"max_secret_groups"`
|
||||
MaxChannels int `json:"max_channels"`
|
||||
}
|
||||
|
||||
const (
|
||||
FeatureConsole = "console" // browser SSH/RDP/VNC
|
||||
FeatureOIDC = "oidc" // per-instance single sign-on
|
||||
)
|
||||
|
||||
const (
|
||||
TierFree = "free"
|
||||
TierProfessional = "professional"
|
||||
TierSelfHosted = "self_hosted"
|
||||
|
||||
DeploymentCloud = "cloud"
|
||||
DeploymentSelfHosted = "self_hosted"
|
||||
)
|
||||
```
|
||||
|
||||
`InstanceID` is **always populated**. There is no unbound license: the
|
||||
self-hosted purchase flow (spec 4) links the instance UUID before the license is
|
||||
issued, so binding happens at signing time. This removes the claim endpoint, the
|
||||
best-effort phone-home and the multi-claim reconciliation that an unbound design
|
||||
would have needed.
|
||||
|
||||
**The server never branches on `Tier`.** It reads `Limits` and `Features` only.
|
||||
`Tier` exists for display, support and analytics. Adding a tier, or changing what
|
||||
a tier includes, must never require a server release.
|
||||
|
||||
### Tier seed values
|
||||
|
||||
Recorded here as the initial contents of spec 3's `plans` table. Snapshotted into
|
||||
each license at issue, so changing the table never rewrites an issued license —
|
||||
the same principle as `workflow_runs.steps_snapshot`.
|
||||
|
||||
| | Free | Professional | Self Hosted |
|
||||
|---|---|---|---|
|
||||
| `deployment` | `cloud` | `cloud` | `self_hosted` |
|
||||
| `max_servers` | 3 | -1 | -1 |
|
||||
| `max_secret_groups` | 1 | -1 | -1 |
|
||||
| `max_channels` | 1 | -1 | -1 |
|
||||
| `console` | no | yes | yes |
|
||||
| `oidc` | no | yes | yes |
|
||||
| billing term | monthly, £0 | monthly or annual | **annual only** |
|
||||
|
||||
Free is cloud-only. A self-hosted install can never hold a valid Free license
|
||||
because Free is only ever signed with `deployment: "cloud"`, and verification
|
||||
rejects a deployment mismatch. There is no server-side flag to edit.
|
||||
|
||||
### Signing
|
||||
|
||||
```go
|
||||
//go:build !noSign
|
||||
|
||||
func Sign(l License, privateKeyHex string) (string, error)
|
||||
```
|
||||
|
||||
Marshals to canonical JSON, signs with lk, returns the base32 blob.
|
||||
|
||||
`Sign` is excluded from the server binary with a build tag. The server has no
|
||||
reason to hold signing code and there is no reason to ship it into a customer's
|
||||
data centre.
|
||||
|
||||
The private key lives in `LICENSE_SIGNING_KEY` on the issuing side only — the
|
||||
CLI now, the admin backend from spec 3. It is never in the repo, never in an
|
||||
image, never in the control plane's environment.
|
||||
|
||||
### Verification
|
||||
|
||||
```go
|
||||
type VerifyOpts struct {
|
||||
InstanceID string // required: the verifier's own instance
|
||||
Deployment string // required: "cloud" or "self_hosted"
|
||||
Now time.Time // injectable for tests
|
||||
}
|
||||
|
||||
type Result struct {
|
||||
License License
|
||||
State State // Valid, Expired, Invalid
|
||||
Reason string
|
||||
}
|
||||
|
||||
const (
|
||||
StateValid State = "valid"
|
||||
StateExpired State = "expired"
|
||||
StateInvalid State = "invalid"
|
||||
)
|
||||
|
||||
func Verify(blob string, opts VerifyOpts) Result
|
||||
```
|
||||
|
||||
Checks, in order, stopping at the first failure:
|
||||
|
||||
1. Blob decodes and the signature verifies against one of `trustedPublicKeys`.
|
||||
Failure → `Invalid`, reason `bad_signature`.
|
||||
2. `l.Deployment == opts.Deployment`. Failure → `Invalid`, reason
|
||||
`deployment_mismatch`. This is the check that makes Free cloud-only.
|
||||
3. `l.InstanceID == opts.InstanceID`. Failure → `Invalid`, reason
|
||||
`instance_mismatch`.
|
||||
4. `opts.Now.Before(l.ExpiresAt)`. Failure → `Expired`.
|
||||
5. Otherwise `Valid`.
|
||||
|
||||
**`Expired` and `Invalid` are distinct states and the caller treats them
|
||||
differently in messaging** (spec 2), even though both degrade the instance the
|
||||
same way. A customer whose card failed and a customer who pasted the wrong blob
|
||||
need different words.
|
||||
|
||||
`Parse(blob) (License, error)` verifies the signature only, ignoring binding and
|
||||
expiry. Used by the admin site to display a license and by support to inspect a
|
||||
blob a customer has emailed in. Never used for enforcement.
|
||||
|
||||
Clock skew: no tolerance is applied. Terms are a month or a year; a server whose
|
||||
clock is wrong by enough to matter has bigger problems, and a tolerance window is
|
||||
a thing to get wrong. `Verify` logs at warn level if `IssuedAt` is in the future,
|
||||
which is the signal that a clock is badly off.
|
||||
|
||||
### Key management
|
||||
|
||||
```go
|
||||
// trustedPublicKeys is ordered. Index 0 is the current signing key.
|
||||
// To rotate: prepend the new key, ship a server release, then reissue.
|
||||
// Remove a retired key only after every license signed with it has expired.
|
||||
var trustedPublicKeys = []string{
|
||||
"<hex ed25519 public key>",
|
||||
}
|
||||
```
|
||||
|
||||
A slice from day one even though it holds one entry, because retrofitting a
|
||||
single-key verifier into a multi-key one during an incident is not a thing to
|
||||
plan for.
|
||||
|
||||
Public keys are compiled in. They are not configurable, because a configurable
|
||||
trust root is a licensing bypass: a self-hosted operator could point it at a
|
||||
keypair they generated.
|
||||
|
||||
Key generation is a documented one-off:
|
||||
|
||||
```
|
||||
go run ./shared/license/cmd/lkgen keypair
|
||||
```
|
||||
|
||||
prints a private key hex for the vault and a public key hex to paste into
|
||||
`keys.go`. The private key is stored in a password manager and in the admin
|
||||
service's environment. **If it is lost, no new licenses can be issued for any
|
||||
existing customer without a server release.** Back it up in two places.
|
||||
|
||||
### CLI issuer
|
||||
|
||||
`shared/license/cmd/lkctl`, built only for internal use:
|
||||
|
||||
```
|
||||
lkctl keypair
|
||||
lkctl issue --instance-id=<uuid> --instance-name="Acme" \
|
||||
--tier=professional --deployment=cloud \
|
||||
--term=1y [--account-id=<id>] [--out=acme.lic]
|
||||
lkctl inspect <file-or-blob>
|
||||
```
|
||||
|
||||
`issue` reads `LICENSE_SIGNING_KEY`, applies the tier seed values from a table
|
||||
compiled into the CLI, and prints the blob. `--term` accepts `1m`, `1y` or an
|
||||
explicit `--expires=RFC3339`.
|
||||
|
||||
This is the production issuance path until spec 3 ships. It is kept afterwards
|
||||
for support and disaster recovery — if the admin service is down and a customer's
|
||||
license expires, a blob can still be cut by hand.
|
||||
|
||||
Issued blobs from `lkctl` are not recorded anywhere. Spec 3 backfills its
|
||||
`licenses` table from `inspect` output when it takes over.
|
||||
|
||||
## Testing
|
||||
|
||||
`shared/license` is pure and needs no database, so this suite is fast and
|
||||
thorough. Written test-first.
|
||||
|
||||
1. Round trip: `Sign` then `Verify` returns `Valid` with an identical payload.
|
||||
2. Tampering: flip one character of the blob → `Invalid`, `bad_signature`.
|
||||
3. Tampering with intent: re-sign a payload with a *different* keypair →
|
||||
`Invalid`. This is the test that proves an attacker cannot mint licenses.
|
||||
4. Expiry: `ExpiresAt` one second in the past → `Expired`. One second in the
|
||||
future → `Valid`.
|
||||
5. Deployment mismatch: a Free (`cloud`) license verified with
|
||||
`Deployment: "self_hosted"` → `Invalid`, `deployment_mismatch`.
|
||||
6. Instance mismatch: correct signature, different `InstanceID` → `Invalid`,
|
||||
`instance_mismatch`.
|
||||
7. Check order: a blob that is both expired *and* instance-mismatched reports
|
||||
`instance_mismatch`, not `Expired`. Order is part of the contract because the
|
||||
reason drives the message.
|
||||
8. Multi-key: a license signed with `trustedPublicKeys[1]` verifies. One signed
|
||||
with a key not in the slice does not.
|
||||
9. `Parse` returns the payload for an expired and for a mismatched license, and
|
||||
errors for a bad signature.
|
||||
10. Unicode and long instance names survive the round trip.
|
||||
11. Golden blob: a fixture blob checked into the repo, signed with a **test-only**
|
||||
keypair, must keep verifying. This catches an accidental change to the
|
||||
canonical JSON encoding, which would silently invalidate every issued
|
||||
license in the field.
|
||||
|
||||
Test 11 matters more than it looks. The encoding is part of the wire format.
|
||||
|
||||
## Verification before merge
|
||||
|
||||
1. `go test ./shared/license/...` passes, including the golden fixture.
|
||||
2. `lkctl keypair` → `lkctl issue` → `lkctl inspect` round trips at the command
|
||||
line.
|
||||
3. `go build -tags noSign ./server/...` succeeds and
|
||||
`go tool nm` on the resulting binary shows no `license.Sign` symbol.
|
||||
4. The production keypair is generated, the private half stored in two places,
|
||||
and the public half committed in `keys.go`.
|
||||
|
||||
## Risks
|
||||
|
||||
| Risk | Mitigation |
|
||||
|---|---|
|
||||
| Signing key lost | Documented two-location backup; generation is a one-off with an explicit checklist |
|
||||
| Signing key leaked | Rotation path exists from day one: prepend key, release, reissue. Retire the old key once its licenses expire |
|
||||
| Canonical encoding changes | Golden fixture test |
|
||||
| Signing code shipped to customers | Build tag plus a symbol check in verification |
|
||||
| No revocation | Accepted and documented. Bounded by term length; Self Hosted is annual-only |
|
||||
@@ -0,0 +1,271 @@
|
||||
# Spec 5 — Paddle Billing
|
||||
|
||||
Date: 2026-07-24
|
||||
Status: Design approved, not implemented
|
||||
Depends on: spec 3 (`admin-backend`)
|
||||
Ships: after spec 3. Can be developed in parallel with spec 4.
|
||||
|
||||
## Context
|
||||
|
||||
Paddle is merchant of record: it owns checkout, tax, invoices, dunning and the
|
||||
customer billing portal. This spec connects Paddle's subscription lifecycle to
|
||||
the licence issuance functions spec 3 defines, and moves cloud signup off
|
||||
sitesvc.
|
||||
|
||||
The central constraint, restated because every table below follows from it:
|
||||
**licences are offline-verified, so nothing Paddle says can revoke one early.**
|
||||
Cancellation takes effect when the licence expires. Self Hosted is annual-only to
|
||||
bound that window; the alternative — a customer holding a valid key for eleven
|
||||
months after cancelling a monthly plan — is not acceptable.
|
||||
|
||||
## Goals
|
||||
|
||||
1. A catalog in Paddle sandbox, promotable to production by configuration alone.
|
||||
2. Webhooks that issue and renew licences reliably, including under retries and
|
||||
out-of-order delivery.
|
||||
3. Cloud signup owned by one service instead of two.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Building any part of billing Paddle already provides.
|
||||
- Usage-based or metered pricing. Tiers are flat.
|
||||
- Proration logic. Paddle handles money; we react to the resulting subscription
|
||||
state.
|
||||
|
||||
## Design
|
||||
|
||||
### Catalog
|
||||
|
||||
Three products, created in **sandbox** first. Production is a configuration
|
||||
change: the same `plans` rows carry different `paddle_product_id` and
|
||||
`paddle_price_ids`, selected by `PADDLE_ENV`.
|
||||
|
||||
| Product | Prices | Notes |
|
||||
|---|---|---|
|
||||
| Vantage Free | monthly, £0 | Yes, a real £0 subscription. It gives every account a Paddle customer, a lifecycle, and an upgrade path with no special-case code. |
|
||||
| Vantage Professional | monthly, annual | Cloud |
|
||||
| Vantage Self Hosted | **annual only** | No monthly price exists, so the offline-revocation window is at most a year |
|
||||
|
||||
**No price ID is ever hard-coded.** They live in `plans.paddle_price_ids` and are
|
||||
edited through the staff UI. A price change in Paddle is a data edit, not a
|
||||
deploy.
|
||||
|
||||
`custom_data` on every checkout carries `{ account_id, instance_id, tier }`. This
|
||||
is what lets a webhook route without a lookup table, and it is why the
|
||||
self-hosted flow creates the instance record *before* checkout completes.
|
||||
|
||||
### Checkout
|
||||
|
||||
Paddle Checkout, overlay mode, in the admin site.
|
||||
|
||||
**Cloud upgrade** — instance exists, `instance_id` in `custom_data`, existing
|
||||
Paddle customer reused.
|
||||
|
||||
**Self-hosted purchase** — the instance does not exist yet. Order:
|
||||
|
||||
```
|
||||
Customer creates an admin-site account (verified email)
|
||||
Account row created, then an admin_instances row with status awaiting_link
|
||||
and a generated placeholder instance record
|
||||
Checkout opened with account_id and that instance row's id in custom_data
|
||||
subscription.created fires → subscription recorded, status awaiting_link,
|
||||
NO licence issued
|
||||
Customer pastes their install's UUID → instance_id set, status active
|
||||
→ licence issued and delivered
|
||||
```
|
||||
|
||||
The instance row exists before payment so the webhook has something to attach to.
|
||||
The licence is not issued until the UUID is known, because a licence with no
|
||||
instance to bind to cannot be signed — spec 1 has no unbound licence.
|
||||
|
||||
A customer who pays and never links has a subscription and no licence. Spec 4's
|
||||
staff dashboard flags `awaiting_link` older than 48 hours, and a reminder email
|
||||
goes out at 24 hours and 72 hours. This is the most likely place for a paying
|
||||
customer to get stuck, so it gets active chasing rather than a support queue.
|
||||
|
||||
### Webhooks
|
||||
|
||||
`POST /api/paddle/webhook`, signature-verified with `PADDLE_WEBHOOK_SECRET`.
|
||||
An unsigned or badly signed request is rejected `401` and logged — never
|
||||
processed.
|
||||
|
||||
**Idempotency is mandatory.** Paddle retries. Every event ID is recorded in
|
||||
`paddle_events` with a unique index before processing; a duplicate returns `200`
|
||||
without acting. `200` on duplicates matters — returning an error would make
|
||||
Paddle retry a message we have already handled, forever.
|
||||
|
||||
| Event | Action |
|
||||
|---|---|
|
||||
| `subscription.created` | Record the subscription. Cloud: issue and inject. Self-hosted: leave `awaiting_link`, issue nothing. |
|
||||
| `subscription.updated` | Tier or term changed: issue a replacement licence at the new tier, supersede the old. Cloud injects; self-hosted emails a new blob and flags the site. Reflects Paddle's resulting state; no proration maths here. |
|
||||
| `subscription.canceled` | Mark `cancelled`. **No licence action.** The current licence runs to expiry, then the instance degrades per spec 2. |
|
||||
| `subscription.past_due` | Mark `past_due`, notify the customer, flag for staff. Licence untouched. Dunning is Paddle's job; ours is not to punish a retryable card failure. |
|
||||
| `transaction.completed` where the transaction is a subscription renewal | Issue the next term's licence, supersede, inject or email. Reset `RelinkCount`. |
|
||||
| `transaction.payment_failed` | Record for staff visibility. No licence action. |
|
||||
| `customer.updated` | Sync `billing_email` onto the account. |
|
||||
|
||||
Out-of-order delivery is handled by making every handler a function of the
|
||||
subscription's *current* state as reported in the event payload, rather than of
|
||||
the transition. An `updated` arriving before its `created` creates the
|
||||
subscription row and proceeds.
|
||||
|
||||
Renewal licences are issued with a **3-day grace** past the period end (spec 3),
|
||||
so a webhook delayed by hours never produces a gap in coverage.
|
||||
|
||||
**Webhook failures must be visible.** Every failed handler writes to
|
||||
`admin_audit` and appears on the staff dashboard. A licence that silently failed
|
||||
to issue is a customer who paid and got nothing.
|
||||
|
||||
### Cancellation, stated plainly
|
||||
|
||||
When a customer cancels:
|
||||
|
||||
- Paddle stops billing at period end.
|
||||
- We issue no further licences.
|
||||
- Their current licence keeps working until it expires — up to a month for
|
||||
Professional monthly, up to a year for Self Hosted.
|
||||
- On expiry the instance degrades per spec 2: monitors keep running, changes stop.
|
||||
|
||||
This is documented in the terms and shown on the cancellation confirmation
|
||||
screen, because a customer who cancels and sees their instance keep working
|
||||
should understand why rather than assume the cancellation failed.
|
||||
|
||||
### Signup migration off sitesvc
|
||||
|
||||
Cloud signup currently lives in sitesvc: `site_pending_signups`, a verification
|
||||
email, and provisioning on link click. It now needs to also create an Account, a
|
||||
Paddle customer, a Free subscription and a licence.
|
||||
|
||||
**Signup moves to the admin backend.** The form stays on the marketing site where
|
||||
customers find it, but it posts to admin instead of sitesvc. sitesvc keeps the
|
||||
contact form only.
|
||||
|
||||
The reason is the one `CLAUDE.md` already names: provisioning logic duplicated
|
||||
across services drifts. Spec 0a removed the second copy; adding signup to admin
|
||||
while leaving it in sitesvc would create a third.
|
||||
|
||||
New flow, preserving every property of the current one:
|
||||
|
||||
```
|
||||
Marketing site form → POST /api/signup on admin
|
||||
→ pending record, password bcrypt cost 12, token 32 random bytes,
|
||||
only the SHA-256 hash stored, 24h expiry, TTL index
|
||||
→ verification email
|
||||
Link opened → FindOneAndDelete the pending record (atomic, before provisioning)
|
||||
→ shared.CreateInstance + shared.CreateUser in the control plane
|
||||
→ Account created
|
||||
→ Paddle customer created, Free subscription created
|
||||
→ Free licence issued and injected
|
||||
→ redirect to APP_LOGIN_URL with {slug} filled in
|
||||
```
|
||||
|
||||
Properties that must survive, verified by test:
|
||||
|
||||
- Nothing written to `instances` or `users` until the link is opened.
|
||||
- `FindOneAndDelete` before provisioning, so a double-clicked link cannot create
|
||||
two instances.
|
||||
- Instance rollback if the owner insert fails, refusing to delete an instance
|
||||
that has users.
|
||||
- Re-submitting for the same address replaces the pending record.
|
||||
- Rate limited to 3 signups per IP per hour, plus the honeypot field.
|
||||
|
||||
Two failure modes are new, because provisioning now spans two systems:
|
||||
|
||||
- **Paddle customer creation fails** — the instance and user are already created.
|
||||
Complete the signup, record the account with an empty `PaddleCustomerID`, issue
|
||||
the Free licence anyway, and flag for staff. A new customer must never be
|
||||
blocked from signing in by a billing-system hiccup.
|
||||
- **Licence issuance fails** — the instance exists with no licence and is
|
||||
read-only. Flagged for staff, and the 15-minute reconciliation job (spec 3)
|
||||
retries. The customer can log in and sees the licence banner.
|
||||
|
||||
Both resolve toward "the customer gets in", because a signup that half-fails
|
||||
silently is worse than either outcome.
|
||||
|
||||
sitesvc changes: signup, verify, `site_pending_signups` and the provisioning
|
||||
calls are deleted. `SITE_API_URL` gains a sibling for the admin endpoint, or the
|
||||
marketing site posts signup to `ADMIN_API_URL` directly — the latter, so the two
|
||||
form targets are explicit rather than implied.
|
||||
|
||||
### Configuration
|
||||
|
||||
| Variable | Required | Notes |
|
||||
|---|---|---|
|
||||
| `PADDLE_ENV` | yes | `sandbox` or `production`; selects which price IDs the plans table serves |
|
||||
| `PADDLE_API_KEY` | yes | server-side API |
|
||||
| `PADDLE_CLIENT_TOKEN` | yes | browser checkout; baked into the admin site build |
|
||||
| `PADDLE_WEBHOOK_SECRET` | yes | signature verification. Boot fails without it — an unverified webhook endpoint is an endpoint anyone can issue licences through |
|
||||
| `APP_LOGIN_URL` | yes | moved from sitesvc; `{slug}` template |
|
||||
|
||||
### Cutover
|
||||
|
||||
Signup migration is the only user-visible switch:
|
||||
|
||||
1. Deploy admin with signup enabled; sitesvc still serving its own.
|
||||
2. Point the marketing site's form at admin. Deploy.
|
||||
3. Let sitesvc's outstanding pending signups expire naturally — 24 hours — while
|
||||
its verify endpoint stays live. **Do not delete the collection until it is
|
||||
empty**, or someone's verification link breaks.
|
||||
4. Deploy sitesvc with signup removed.
|
||||
|
||||
## Testing
|
||||
|
||||
**Webhooks:**
|
||||
|
||||
1. Each event type produces its documented action against a mock Paddle payload.
|
||||
2. Replaying an event ID is a no-op returning `200`.
|
||||
3. A bad signature is rejected `401` and processes nothing.
|
||||
4. `subscription.updated` before `subscription.created` creates the subscription
|
||||
and applies the update.
|
||||
5. `subscription.canceled` issues nothing and leaves the current licence intact.
|
||||
6. `past_due` leaves the licence intact and flags the account.
|
||||
7. Renewal issues the next term, supersedes, resets `RelinkCount`, and the new
|
||||
`ExpiresAt` is period end plus 3 days.
|
||||
8. A handler failure writes to `admin_audit` and surfaces on the dashboard.
|
||||
|
||||
**Checkout:**
|
||||
|
||||
9. `custom_data` round-trips account, instance and tier through to the webhook.
|
||||
10. Self-hosted checkout leaves the instance `awaiting_link` with no licence.
|
||||
11. Linking after checkout issues the licence.
|
||||
|
||||
**Signup:**
|
||||
|
||||
12. Nothing is written to `instances` or `users` before the link is opened.
|
||||
13. A double-clicked verification link creates exactly one instance.
|
||||
14. Owner-insert failure rolls the instance back; rollback refuses an instance
|
||||
with users.
|
||||
15. Re-submitting replaces the pending record and invalidates the earlier link.
|
||||
16. Rate limit and honeypot both reject.
|
||||
17. Paddle customer creation failure still completes signup and issues the Free
|
||||
licence.
|
||||
18. Licence issuance failure still lets the user log in, showing the banner.
|
||||
19. Expired pending records are dropped by the TTL index.
|
||||
|
||||
## Verification before merge
|
||||
|
||||
1. Full suite green.
|
||||
2. Against Paddle **sandbox**, end to end for each tier: checkout with a test
|
||||
card, confirm the licence is issued, confirm the instance reports `valid`.
|
||||
3. Trigger a sandbox renewal and confirm the next term's licence arrives and is
|
||||
injected.
|
||||
4. Cancel in sandbox and confirm the licence keeps working to expiry, then the
|
||||
instance degrades correctly — monitors still running.
|
||||
5. Replay every webhook from Paddle's dashboard and confirm no duplicate licences
|
||||
are created.
|
||||
6. Full signup end to end through admin, then confirm the new user can log into
|
||||
their control-plane instance and sees a valid Free licence.
|
||||
7. Confirm sitesvc's pending-signup collection is empty before its signup code is
|
||||
removed.
|
||||
|
||||
## Risks
|
||||
|
||||
| Risk | Mitigation |
|
||||
|---|---|
|
||||
| Duplicate licences from webhook retries | Unique index on event ID, checked before processing |
|
||||
| Webhook missed entirely | 15-minute reconciliation job (spec 3) compares subscription state against issued licences |
|
||||
| Cancellation not enforceable until expiry | Accepted, bounded by term; Self Hosted annual-only; stated in terms and on the cancellation screen |
|
||||
| Signup cutover breaks in-flight verification links | Staged cutover; sitesvc's verify stays live until its collection is empty |
|
||||
| Sandbox price IDs reaching production | `PADDLE_ENV` selects them from the plans table; environment badge in the admin site |
|
||||
| Webhook endpoint unauthenticated | Signature verification mandatory; boot fails without the secret |
|
||||
| Customer pays and never links | Reminder emails at 24h and 72h, staff dashboard alert at 48h |
|
||||
@@ -0,0 +1,286 @@
|
||||
# Spec 0a — Shared Module Extraction
|
||||
|
||||
Date: 2026-07-24
|
||||
Status: Design approved, not implemented
|
||||
Ships: independently. No dependency on any other licensing spec.
|
||||
|
||||
## Context
|
||||
|
||||
Vantage is three independent Go modules: `server`, `sitesvc`, `agent`. There is no
|
||||
root `go.mod` and no `go.work`.
|
||||
|
||||
`sitesvc` writes into the same MongoDB collections the control plane reads, but
|
||||
cannot import the control plane, so it carries hand-copied duplicates:
|
||||
|
||||
- `sitesvc/internal/models/models.go` — `Org` and `User` mirrored field for field
|
||||
- `sitesvc/internal/provision/provision.go` — `Slugify`, `ReservedSlugs`,
|
||||
`MinSlugLength`, `MaxSlugLength`, `BcryptCost`, slug-collision rules
|
||||
|
||||
Both files carry comments saying they must be changed in lockstep with the
|
||||
control plane, and `CLAUDE.md` names the hazard explicitly: nothing enforces the
|
||||
match. **The duplication has already drifted.** The control plane's `CreateOrg`
|
||||
resolves slug collisions with an inline `fmt.Sprintf("%s-%d", base, i)` loop,
|
||||
while sitesvc exposes the same rule as a separate `NextSlug(base, attempt)`
|
||||
helper. They currently agree by luck, not by construction.
|
||||
|
||||
The licensing programme adds a fourth service (`admin`) that writes the license
|
||||
blob onto the same tenant document. Adding a third copy of these rules is not
|
||||
acceptable. This spec removes the duplication before any licensing code is
|
||||
written.
|
||||
|
||||
This spec is a **pure refactor**. No database document changes. No behaviour
|
||||
changes. Names stay as they are today (`Org`, `org_id`) — renaming happens in
|
||||
spec 0b, deliberately kept separate so that a failed deploy has one suspect
|
||||
rather than two.
|
||||
|
||||
## Goals
|
||||
|
||||
1. One authoritative definition of every document shape written by more than one
|
||||
service.
|
||||
2. One authoritative definition of provisioning rules (slug, bcrypt cost,
|
||||
creation, rollback).
|
||||
3. `sitesvc` keeps its independence from `server` — it depends on `shared`, not
|
||||
on the control plane. The original design intent survives; only the copying
|
||||
dies.
|
||||
4. The agent is untouched.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Renaming anything. That is spec 0b.
|
||||
- Moving control-plane-only models. `workflow.go`, `monitor.go`, `key.go`,
|
||||
`server.go`, `secret.go`, `assignment.go`, `channel.go`, `console_session.go`,
|
||||
`audit.go`, `org_oidc.go` stay in `server/internal/models`. Only the control
|
||||
plane touches them, and hoisting them would make `shared` a dumping ground.
|
||||
- Merging the repo into a single module.
|
||||
|
||||
## Design
|
||||
|
||||
### Module layout
|
||||
|
||||
```
|
||||
vantage/
|
||||
├── go.work # NEW: server, sitesvc, shared (NOT agent)
|
||||
├── shared/ # NEW module: github.com/mrhid6/vantage/shared
|
||||
│ ├── go.mod
|
||||
│ ├── models/
|
||||
│ │ ├── org.go # Org
|
||||
│ │ ├── user.go # User, RoleOwner/RoleAdmin/RoleMember, ValidRole
|
||||
│ │ └── settings.go # Settings, AlertSettings, EmailSettings, SecretsSettings
|
||||
│ ├── provision/
|
||||
│ │ ├── slug.go # Slugify, BaseSlug, NextSlug, ReservedSlugs, limits
|
||||
│ │ ├── org.go # CreateOrg
|
||||
│ │ ├── user.go # CreateUser, BcryptCost
|
||||
│ │ └── rollback.go # RollbackOrg
|
||||
│ └── indexes/
|
||||
│ └── indexes.go # EnsureCoreIndexes
|
||||
├── server/ # replace => ../shared
|
||||
├── sitesvc/ # replace => ../shared
|
||||
└── agent/ # untouched
|
||||
```
|
||||
|
||||
`go.work`:
|
||||
|
||||
```
|
||||
go 1.26
|
||||
|
||||
use (
|
||||
./shared
|
||||
./server
|
||||
./sitesvc
|
||||
)
|
||||
```
|
||||
|
||||
Each consumer's `go.mod` also carries an explicit replace:
|
||||
|
||||
```
|
||||
require github.com/mrhid6/vantage/shared v0.0.0
|
||||
replace github.com/mrhid6/vantage/shared => ../shared
|
||||
```
|
||||
|
||||
Both are needed. `go.work` makes editors, `go test ./...` and local tooling work
|
||||
across modules. The `replace` directives make Docker builds work whether or not
|
||||
`go.work` is present, and stop `go build` outside the workspace from silently
|
||||
trying to resolve `shared` from the network.
|
||||
|
||||
`shared` depends only on `go.mongodb.org/mongo-driver/v2`,
|
||||
`golang.org/x/crypto/bcrypt` and `github.com/google/uuid`. It must not import
|
||||
gin, redis, guac or anything else from the control plane's tree — that is what
|
||||
keeps sitesvc small.
|
||||
|
||||
### What moves
|
||||
|
||||
**`shared/models`** — the three documents written by more than one service:
|
||||
|
||||
| Type | From | Written by |
|
||||
|---|---|---|
|
||||
| `Org` | `server/internal/models/org.go` | server, sitesvc, later admin |
|
||||
| `User` + role constants + `ValidRole` | `server/internal/models/user.go` | server, sitesvc |
|
||||
| `Settings` and its sub-structs | `server/internal/models/settings.go` | server today; admin reads it later |
|
||||
|
||||
`Settings` moves now rather than later because spec 3's admin service reads it,
|
||||
and moving it later would mean a second round of import churn across both
|
||||
services.
|
||||
|
||||
`PendingSignup` does **not** move. Only sitesvc writes `site_pending_signups`,
|
||||
and the control plane does not know the collection exists.
|
||||
|
||||
**`shared/provision`** — the rules, promoted from private helpers to a real API:
|
||||
|
||||
```go
|
||||
const (
|
||||
MinSlugLength = 3
|
||||
MaxSlugLength = 40
|
||||
BcryptCost = 12
|
||||
)
|
||||
|
||||
var ReservedSlugs = map[string]bool{ /* www, api, app, admin, auth, install, static, _next, default */ }
|
||||
|
||||
func Slugify(name string) string
|
||||
func BaseSlug(name string) (string, error) // validates length + reserved
|
||||
func NextSlug(base string, attempt int) string
|
||||
|
||||
// CreateOrg resolves a free slug and inserts. The caller supplies the
|
||||
// collection handle so shared does not own a Mongo connection.
|
||||
func CreateOrg(ctx context.Context, db *mongo.Database, name string) (*models.Org, error)
|
||||
|
||||
func CreateUser(ctx context.Context, db *mongo.Database, orgID, email, password, role string) (*models.User, error)
|
||||
|
||||
// RollbackOrg deletes an org only if it has no users. Refuses otherwise.
|
||||
func RollbackOrg(ctx context.Context, db *mongo.Database, orgID string) error
|
||||
```
|
||||
|
||||
`shared.CreateOrg` becomes the single implementation. The control plane's
|
||||
`services.CreateOrg` shrinks to a wrapper that calls it and then runs
|
||||
`SeedDefaultSteps` — seeding stays in the server, because `shared` must not know
|
||||
about workflow steps. sitesvc calls `shared.CreateOrg` directly and does not
|
||||
seed, which is the behaviour it has today.
|
||||
|
||||
Note on the slug loop: it is count-then-insert and therefore racy. It is safe
|
||||
only because of the unique index on `orgs.slug`. `CreateOrg` must keep handling
|
||||
`mongo.IsDuplicateKeyError` and returning a clean error — moving the code must
|
||||
not lose that. Document the reliance in a comment at the loop.
|
||||
|
||||
**`shared/indexes`** — `EnsureCoreIndexes(ctx, db)` declares the unique indexes
|
||||
on `users.email` and `orgs.slug`. Both services call it at boot; creating an
|
||||
existing index is a no-op. These indexes are a security property, not an
|
||||
optimisation (see `CLAUDE.md`: `GetUserByEmail` does an unscoped `FindOne`, so
|
||||
duplicates would break the OIDC cross-org guard), so the shared version is
|
||||
**fatal on failure** for both callers.
|
||||
|
||||
Server-only index builders (`EnsureSettingsIndexes`, `EnsureSecretIndexes`,
|
||||
`EnsureWorkflowIndexes`) stay in the server and keep their current
|
||||
fatal/warn behaviour.
|
||||
|
||||
### What is deleted
|
||||
|
||||
- `sitesvc/internal/models/models.go` — reduced to `PendingSignup` only
|
||||
- `sitesvc/internal/provision/` — deleted entirely
|
||||
- `sitesvc/internal/store/store.go` — org/user creation replaced by calls into
|
||||
`shared/provision`; pending-signup storage stays
|
||||
|
||||
### Docker and CI
|
||||
|
||||
Both Go Dockerfiles currently build with the module directory as context:
|
||||
|
||||
```dockerfile
|
||||
WORKDIR /app
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
COPY . .
|
||||
RUN go build ... ./cmd
|
||||
```
|
||||
|
||||
A `replace => ../shared` cannot resolve from that context. Build contexts move
|
||||
to the repo root:
|
||||
|
||||
```dockerfile
|
||||
WORKDIR /src
|
||||
COPY shared/go.mod shared/go.sum ./shared/
|
||||
COPY server/go.mod server/go.sum ./server/
|
||||
RUN cd server && go mod download
|
||||
COPY shared/ ./shared/
|
||||
COPY server/ ./server/
|
||||
ARG VERSION=dev
|
||||
RUN cd server && CGO_ENABLED=0 GOOS=linux go build \
|
||||
-ldflags="-s -w -X main.Version=${VERSION}" -o /vantage-server ./cmd
|
||||
```
|
||||
|
||||
The two-stage copy keeps the dependency-download layer cached, which is the
|
||||
reason the current Dockerfiles are written the way they are.
|
||||
|
||||
`.gitea/workflows/server-deploy.yml` must set `context: .` and
|
||||
`file: server/Dockerfile` (and likewise for sitesvc) for the two Go images. The
|
||||
`web` and `site` image builds are unaffected.
|
||||
|
||||
`agent-release.yml` is untouched. The agent is not in the workspace, has no
|
||||
`replace`, and cross-compiles exactly as it does today.
|
||||
|
||||
### Error handling
|
||||
|
||||
No new error paths. `shared/provision` returns the same error strings the two
|
||||
callers produce today so that API responses do not change. The one place to be
|
||||
careful is wording: sitesvc says "organisation" and the control plane says
|
||||
"organization". `shared` standardises on **"organisation"**; the control plane's
|
||||
two error strings change spelling. This is user-visible in API error text and is
|
||||
called out here so it is a decision rather than an accident.
|
||||
|
||||
## Testing
|
||||
|
||||
**No automated tests.** Decision taken 2026-07-24: the repo has no Go test suite
|
||||
and one is not being started here. Verification is by compiler, `grep`, and
|
||||
running both services end to end.
|
||||
|
||||
That places the whole weight on three manual checks, which the implementation
|
||||
plan makes mandatory steps rather than suggestions:
|
||||
|
||||
1. **bson tag diff** — `diff` the `bson:"…"` tags of each moved struct against
|
||||
the originals. A changed tag orphans production data silently, and this is
|
||||
the only thing that catches it.
|
||||
2. **Slug behaviour walkthrough** — a throwaway `main` printing `Slugify`,
|
||||
`BaseSlug` and `NextSlug` output for a fixed input table, compared against
|
||||
expected output recorded in the plan.
|
||||
3. **End-to-end agreement** — sign up through sitesvc against a scratch
|
||||
database, open the verification link, then log into the control plane with
|
||||
those credentials. This is the check that proves the two services still agree
|
||||
about the documents they share. If it passes, the refactor worked.
|
||||
|
||||
Plus `grep` assertions that exactly one definition of `Slugify` and
|
||||
`ReservedSlugs` survives repo-wide, and that no struct under `sitesvc/` carries
|
||||
a `bson:"org_id"` tag.
|
||||
|
||||
## Verification before merge
|
||||
|
||||
Evidence required, not assertions:
|
||||
|
||||
1. `go build ./...` succeeds in `shared`, `server` and `sitesvc`.
|
||||
2. `go vet ./...` clean in all three.
|
||||
3. `docker build -f server/Dockerfile .` and `docker build -f sitesvc/Dockerfile .`
|
||||
both succeed from the repo root.
|
||||
4. `grep -r "org_id" sitesvc/` returns hits only in `PendingSignup` context and
|
||||
`shared` imports — no local struct redefinitions.
|
||||
5. End-to-end against a scratch database: sitesvc signup form → verification link
|
||||
→ org and owner created → that owner logs into the control plane
|
||||
successfully. This is the test that proves the two services still agree.
|
||||
6. The agent still builds for `linux/amd64`, `linux/arm64` and `windows/amd64`.
|
||||
|
||||
## Rollout
|
||||
|
||||
Single release. `server` and `sitesvc` images must be deployed together — a skew
|
||||
is harmless here (documents are unchanged) but there is no reason to split it.
|
||||
|
||||
No database migration. No downtime.
|
||||
|
||||
## Risks
|
||||
|
||||
| Risk | Mitigation |
|
||||
|---|---|
|
||||
| Docker context change breaks CI | Verified locally by building both images from root before pushing |
|
||||
| Behaviour drift while moving `CreateOrg` | Unit tests written against current behaviour first, then the move |
|
||||
| `shared` accumulating control-plane concerns | Explicit non-goals above; keep its `go.mod` dependency list to three entries and review any addition |
|
||||
| Error-string spelling change | Called out as a decision; grep the web UI for hard-coded matches on the old strings |
|
||||
|
||||
## Follow-on
|
||||
|
||||
Spec 0b (`instance-rename`) becomes a rename inside one module plus its
|
||||
consumers, rather than a rename across three independent copies. That is the
|
||||
whole reason this spec goes first.
|
||||
@@ -0,0 +1,67 @@
|
||||
# Vantage Licensing Programme — Spec Index
|
||||
|
||||
Seven specs, designed 2026-07-24. Build in this order.
|
||||
|
||||
| # | Spec | Ships alone | Blocks |
|
||||
|---|---|---|---|
|
||||
| 0a | [shared-module](2026-07-24-shared-module-design.md) | yes | everything |
|
||||
| 0b | [instance-rename](2026-07-24-instance-rename-design.md) | yes | 1, 2, 3 |
|
||||
| 1 | [licensing-core](2026-07-24-licensing-core-design.md) | yes | 2, 3 |
|
||||
| 2 | [instance-licensing](2026-07-24-instance-licensing-design.md) | yes, with `lkctl`-issued licences | — |
|
||||
| 3 | [admin-backend](2026-07-24-admin-backend-design.md) | no | 4, 5 |
|
||||
| 4 | [admin-site](2026-07-24-admin-site-design.md) | no | — |
|
||||
| 5 | [paddle-billing](2026-07-24-paddle-billing-design.md) | no | — |
|
||||
|
||||
4 and 5 can run in parallel once 3 lands.
|
||||
|
||||
## The shape
|
||||
|
||||
```
|
||||
Account (admin only)
|
||||
├── Instance 1 cloud vantage.hostxtra.co.uk/<slug> licence auto-injected
|
||||
├── Instance 2 cloud licence auto-injected
|
||||
└── Instance 3 self-hosted customer's own deployment licence pasted by hand
|
||||
```
|
||||
|
||||
The control plane knows only **Instance**. Accounts exist solely in the admin
|
||||
service, because a self-hosted instance has no row in the cloud database at all.
|
||||
|
||||
## Decisions that everything else follows from
|
||||
|
||||
**Licences are offline-verified signed blobs.** ed25519 via
|
||||
`github.com/hyperboloide/lk`, public key compiled into the server, no phone-home
|
||||
anywhere. This buys air-gapped self-hosting and means no Vantage instance ever
|
||||
depends on the licensing service being up. It costs revocation: a licence is
|
||||
valid until it expires whatever Paddle later says. Self Hosted is annual-only to
|
||||
bound that window.
|
||||
|
||||
**Every licence is bound to one instance UUID.** Self-hosted customers link their
|
||||
UUID before the licence is signed, so there is no unbound licence and no claim
|
||||
protocol.
|
||||
|
||||
**Expiry degrades, it does not break.** Monitors keep executing, alerts keep
|
||||
firing, agents keep their keys, in-flight workflow runs finish. Mutations stop.
|
||||
Deletes and OS-update application stay open so a customer is never trapped
|
||||
over-limit or unpatched.
|
||||
|
||||
**Tiers are data, not code.** The server reads `Limits` and `Features` and never
|
||||
branches on tier name. Tier contents live in the admin `plans` table and are
|
||||
snapshotted into each issued licence, so editing a plan never rewrites history —
|
||||
the same rule as `workflow_runs.steps_snapshot`.
|
||||
|
||||
| | Free | Professional | Self Hosted |
|
||||
|---|---|---|---|
|
||||
| deployment | cloud only | cloud | self-hosted |
|
||||
| max servers | 3 | unlimited | unlimited |
|
||||
| max secret groups | 1 | unlimited | unlimited |
|
||||
| max channels | 1 | unlimited | unlimited |
|
||||
| console | no | yes | yes |
|
||||
| OIDC | no | yes | yes |
|
||||
| term | monthly, £0 | monthly or annual | annual only |
|
||||
|
||||
Free is cloud-only by construction: it is only ever signed with
|
||||
`deployment: "cloud"`, and verification rejects a deployment mismatch. There is
|
||||
no server-side flag to edit. One Free instance per account.
|
||||
|
||||
**Existing cloud tenants** are grandfathered to Professional, one year out, by
|
||||
migration `0005`.
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
cloud.google.com/go/compute v1.25.1/go.mod h1:oopOIR53ly6viBYxaDhBfJwzUAxf1zE//uf3IB011ls=
|
||||
cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k=
|
||||
github.com/census-instrumentation/opencensus-proto v0.4.1/go.mod h1:4T9NM4+4Vw91VeyqjLS6ao50K5bOcLKN6Q42XnYaRYw=
|
||||
github.com/cncf/xds/go v0.0.0-20240318125728-8a4994d93e50/go.mod h1:5e1+Vvlzido69INQaVO6d87Qn543Xr6nooe9Kz7oBFM=
|
||||
github.com/envoyproxy/go-control-plane v0.12.0/go.mod h1:ZBTaoJ23lqITozF0M6G4/IragXCQKCnYbmlmtHvwRG0=
|
||||
github.com/envoyproxy/protoc-gen-validate v1.0.4/go.mod h1:qys6tmnRsYrQqIhm2bvKZH4Blx/1gTIZ2UKVY1M+Yew=
|
||||
github.com/golang/glog v1.2.0/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwmO+w=
|
||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
|
||||
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
|
||||
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20240318140521-94a12d6c2237/go.mod h1:Z5Iiy3jtmioajWHDGFk7CeugTyHtPvMHA4UTmUkyalE=
|
||||
+12
-7
@@ -1,17 +1,22 @@
|
||||
# Build stage
|
||||
#
|
||||
# Context is the repository root, not server/, because server depends on the
|
||||
# shared module through a replace directive.
|
||||
FROM golang:1.26 AS builder
|
||||
|
||||
WORKDIR /app
|
||||
WORKDIR /src
|
||||
|
||||
# Download dependencies first (layer cache)
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
# Manifests first so the dependency layer caches independently of source edits.
|
||||
COPY shared/go.mod shared/go.sum ./shared/
|
||||
COPY server/go.mod server/go.sum ./server/
|
||||
RUN cd server && go mod download
|
||||
|
||||
# Copy source and build
|
||||
COPY . .
|
||||
COPY shared/ ./shared/
|
||||
COPY server/ ./server/
|
||||
|
||||
ARG VERSION=dev
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w -X main.Version=${VERSION}" -o /vantage-server ./cmd
|
||||
RUN cd server && CGO_ENABLED=0 GOOS=linux go build \
|
||||
-ldflags="-s -w -X main.Version=${VERSION}" -o /vantage-server ./cmd
|
||||
|
||||
# Runtime stage
|
||||
FROM scratch
|
||||
|
||||
+31
-26
@@ -19,9 +19,6 @@ func main() {
|
||||
mongoURI := getEnv("MONGO_URI", "mongodb://localhost:27017")
|
||||
dbName := getEnv("MONGO_DB", "vantage")
|
||||
|
||||
|
||||
|
||||
|
||||
if os.Getenv("GRPC_HOST") == "" {
|
||||
log.Fatal("GRPC_HOST is required (host:port agents dial for gRPC)")
|
||||
}
|
||||
@@ -31,19 +28,13 @@ func main() {
|
||||
}
|
||||
log.Println("connected to MongoDB")
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
if err := services.EnsureAuthIndexes(); err != nil {
|
||||
log.Fatalf("failed to ensure auth indexes: %v", err)
|
||||
}
|
||||
// Migrations 0001 to 0003 still speak the pre-rename shape (orgs, org_id),
|
||||
// so they must run before 0004 renames everything underneath them.
|
||||
if err := services.RunMigrations(); err != nil {
|
||||
log.Fatalf("migration failed: %v", err)
|
||||
}
|
||||
|
||||
|
||||
|
||||
// 0002 must precede 0003: 0003 can create a "default" org, which pushes
|
||||
// 0002 into its ambiguous multi-org branch.
|
||||
if err := services.MigrateSettingsOrg(); err != nil {
|
||||
log.Fatalf("settings org migration failed: %v", err)
|
||||
}
|
||||
@@ -51,13 +42,31 @@ func main() {
|
||||
log.Fatalf("missed org scope migration failed: %v", err)
|
||||
}
|
||||
|
||||
// 0004 renames orgs to instances. It must run BEFORE the index builders:
|
||||
// EnsureAuthIndexes creates instances.slug, which would create an empty
|
||||
// instances collection and make 0004 refuse to rename onto it.
|
||||
migCtx, migCancel := context.WithTimeout(context.Background(), 10*time.Minute)
|
||||
migErr := services.MigrateOrgToInstance(migCtx, db.Database)
|
||||
migCancel()
|
||||
if migErr != nil {
|
||||
log.Fatalf("instance rename migration failed: %v", migErr)
|
||||
}
|
||||
|
||||
assertCtx, assertCancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
assertErr := services.AssertNoScopedCollectionMissed(assertCtx, db.Database)
|
||||
assertCancel()
|
||||
if assertErr != nil {
|
||||
log.Fatalf("scoped collection check failed: %v", assertErr)
|
||||
}
|
||||
|
||||
if err := services.EnsureAuthIndexes(); err != nil {
|
||||
log.Fatalf("failed to ensure auth indexes: %v", err)
|
||||
}
|
||||
|
||||
if err := services.EnsureSecretIndexes(); err != nil {
|
||||
log.Printf("warning: failed to ensure secret indexes: %v", err)
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
if err := services.EnsureSettingsIndexes(); err != nil {
|
||||
log.Fatalf("failed to ensure settings indexes: %v", err)
|
||||
}
|
||||
@@ -66,14 +75,14 @@ func main() {
|
||||
log.Printf("warning: failed to ensure workflow indexes: %v", err)
|
||||
}
|
||||
|
||||
if orgIDs, err := services.ListOrgIDs(); err != nil {
|
||||
log.Printf("warning: failed to list orgs for default step seeding: %v", err)
|
||||
if instanceIDs, err := services.ListInstanceIDs(); err != nil {
|
||||
log.Printf("warning: failed to list instances for default step seeding: %v", err)
|
||||
} else {
|
||||
for _, orgID := range orgIDs {
|
||||
if created, updated, err := services.SeedDefaultSteps(orgID); err != nil {
|
||||
log.Printf("warning: failed to seed default steps for org %s: %v", orgID, err)
|
||||
for _, instanceID := range instanceIDs {
|
||||
if created, updated, err := services.SeedDefaultSteps(instanceID); err != nil {
|
||||
log.Printf("warning: failed to seed default steps for instance %s: %v", instanceID, err)
|
||||
} else {
|
||||
log.Printf("default steps seeded for org %s: %d created, %d updated", orgID, created, updated)
|
||||
log.Printf("default steps seeded for instance %s: %d created, %d updated", instanceID, created, updated)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -86,7 +95,6 @@ func main() {
|
||||
}
|
||||
log.Println("connected to Redis")
|
||||
|
||||
|
||||
go func() {
|
||||
ticker := time.NewTicker(2 * time.Minute)
|
||||
defer ticker.Stop()
|
||||
@@ -97,17 +105,14 @@ func main() {
|
||||
}
|
||||
}()
|
||||
|
||||
|
||||
go func() {
|
||||
if err := grpcserver.StartGRPC(9090); err != nil {
|
||||
log.Fatalf("gRPC server error: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
|
||||
monitorsched.Start(context.Background())
|
||||
|
||||
|
||||
r := gin.New()
|
||||
r.Use(gin.Recovery())
|
||||
r.Use(gin.LoggerWithConfig(gin.LoggerConfig{SkipPaths: []string{"/api/console/tunnel"}}))
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
// Command rename-rollback reverses migration 0004.
|
||||
//
|
||||
// Run it only as part of a decision to revert the release that introduced the
|
||||
// instance rename. It renames instance_id back to org_id and restores the two
|
||||
// collection names. Like the migration, it only renames — it deletes no
|
||||
// documents.
|
||||
//
|
||||
// rename-rollback -uri mongodb://host:27017 -db vantage -confirm
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/mrhid6/vantage/server/internal/services"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo/options"
|
||||
)
|
||||
|
||||
func main() {
|
||||
uri := flag.String("uri", "mongodb://localhost:27017", "MongoDB URI")
|
||||
dbName := flag.String("db", "vantage", "database name")
|
||||
confirm := flag.Bool("confirm", false, "required; refuses to run without it")
|
||||
flag.Parse()
|
||||
|
||||
if !*confirm {
|
||||
log.Fatal("refusing to run without -confirm")
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
client, err := mongo.Connect(options.Client().ApplyURI(*uri))
|
||||
if err != nil {
|
||||
log.Fatalf("connect: %v", err)
|
||||
}
|
||||
defer client.Disconnect(ctx)
|
||||
|
||||
db := client.Database(*dbName)
|
||||
|
||||
// Drop indexes keyed on instance_id first, for the same reason the forward
|
||||
// migration drops the org_id ones: a unique index treats the missing field
|
||||
// as null and rejects the second document the rename touches.
|
||||
for _, c := range services.ScopedCollections {
|
||||
if err := services.DropIndexesKeyedOn(ctx, db, c, "instance_id"); err != nil {
|
||||
log.Fatalf("%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
for _, c := range services.ScopedCollections {
|
||||
res, err := db.Collection(c).UpdateMany(ctx,
|
||||
bson.M{"instance_id": bson.M{"$exists": true}},
|
||||
bson.M{"$rename": bson.M{"instance_id": "org_id"}},
|
||||
)
|
||||
if err != nil {
|
||||
log.Fatalf("rename instance_id in %s: %v", c, err)
|
||||
}
|
||||
if res.ModifiedCount > 0 {
|
||||
log.Printf("%s: reverted %d document(s)", c, res.ModifiedCount)
|
||||
}
|
||||
}
|
||||
|
||||
for _, r := range []struct{ from, to string }{
|
||||
{"instances", "orgs"},
|
||||
{"instance_oidc", "org_oidc"},
|
||||
} {
|
||||
cmd := bson.D{
|
||||
{Key: "renameCollection", Value: *dbName + "." + r.from},
|
||||
{Key: "to", Value: *dbName + "." + r.to},
|
||||
}
|
||||
if err := client.Database("admin").RunCommand(ctx, cmd).Err(); err != nil {
|
||||
log.Printf("rename %s to %s: %v (continuing)", r.from, r.to, err)
|
||||
continue
|
||||
}
|
||||
log.Printf("renamed collection %s to %s", r.from, r.to)
|
||||
}
|
||||
|
||||
// Remove the marker so a redeployed new binary re-runs the migration.
|
||||
if _, err := db.Collection("migrations").DeleteOne(ctx, bson.M{"_id": "0004_org_to_instance"}); err != nil {
|
||||
log.Printf("clear migration marker: %v", err)
|
||||
}
|
||||
|
||||
log.Println("rollback complete")
|
||||
}
|
||||
+12
-10
@@ -1,6 +1,6 @@
|
||||
module github.com/mrhid6/vantage/server
|
||||
|
||||
go 1.26
|
||||
go 1.26.4
|
||||
|
||||
require (
|
||||
github.com/coreos/go-oidc/v3 v3.18.0
|
||||
@@ -8,7 +8,8 @@ require (
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/redis/go-redis/v9 v9.20.1
|
||||
github.com/wwt/guac v1.3.2
|
||||
go.mongodb.org/mongo-driver/v2 v2.2.2
|
||||
go.mongodb.org/mongo-driver/v2 v2.8.0
|
||||
golang.org/x/crypto v0.54.0
|
||||
golang.org/x/oauth2 v0.36.0
|
||||
google.golang.org/grpc v1.64.0
|
||||
)
|
||||
@@ -26,32 +27,33 @@ require (
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.20.0 // indirect
|
||||
github.com/goccy/go-json v0.10.2 // indirect
|
||||
github.com/golang/snappy v1.0.0 // indirect
|
||||
github.com/gorilla/websocket v1.4.1 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/compress v1.16.7 // indirect
|
||||
github.com/klauspost/compress v1.17.6 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
|
||||
github.com/konsorten/go-windows-terminal-sequences v1.0.1 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/mrhid6/vantage/shared v0.0.0
|
||||
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
|
||||
github.com/sirupsen/logrus v1.4.2 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
|
||||
github.com/xdg-go/scram v1.1.2 // indirect
|
||||
github.com/xdg-go/scram v1.2.0 // indirect
|
||||
github.com/xdg-go/stringprep v1.0.4 // indirect
|
||||
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
|
||||
go.uber.org/atomic v1.11.0 // indirect
|
||||
golang.org/x/arch v0.8.0 // indirect
|
||||
golang.org/x/crypto v0.33.0 // indirect
|
||||
golang.org/x/net v0.25.0 // indirect
|
||||
golang.org/x/sync v0.11.0 // indirect
|
||||
golang.org/x/sys v0.30.0 // indirect
|
||||
golang.org/x/text v0.22.0 // indirect
|
||||
golang.org/x/net v0.56.0 // indirect
|
||||
golang.org/x/sync v0.22.0 // indirect
|
||||
golang.org/x/sys v0.47.0 // indirect
|
||||
golang.org/x/text v0.40.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20240521202816-d264139d666e // indirect
|
||||
google.golang.org/protobuf v1.34.2 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
replace github.com/mrhid6/vantage/shared => ../shared
|
||||
|
||||
+16
-18
@@ -35,8 +35,6 @@ github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBEx
|
||||
github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
|
||||
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||
github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs=
|
||||
github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
@@ -47,8 +45,8 @@ github.com/gorilla/websocket v1.4.1 h1:q7AeDBpnBk8AogcD4DSag/Ukw/KV+YhzLj2bP5HvK
|
||||
github.com/gorilla/websocket v1.4.1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/klauspost/compress v1.16.7 h1:2mk3MPGNzKyxErAw8YaohYh69+pa4sIQSC0fPGCFR9I=
|
||||
github.com/klauspost/compress v1.16.7/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE=
|
||||
github.com/klauspost/compress v1.17.6 h1:60eq2E/jlfwQXtvZEeBUYADs+BwKBWURIY+Gj2eRGjI=
|
||||
github.com/klauspost/compress v1.17.6/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM=
|
||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
|
||||
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
@@ -94,8 +92,8 @@ github.com/wwt/guac v1.3.2 h1:sH6OFGa/1tBs7ieWBVlZe7t6F5JAOWBry/tqQL/Vup4=
|
||||
github.com/wwt/guac v1.3.2/go.mod h1:eKm+NrnK7A88l4UBEcYNpZQGMpZRryYKoz4D/0/n1C0=
|
||||
github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c=
|
||||
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
|
||||
github.com/xdg-go/scram v1.1.2 h1:FHX5I5B4i4hKRVRBCFRxq1iQRej7WO3hhBuJf+UUySY=
|
||||
github.com/xdg-go/scram v1.1.2/go.mod h1:RT/sEzTbU5y00aCK8UOx6R7YryM0iF1N2MOmC3kKLN4=
|
||||
github.com/xdg-go/scram v1.2.0 h1:bYKF2AEwG5rqd1BumT4gAnvwU/M9nBp2pTSxeZw7Wvs=
|
||||
github.com/xdg-go/scram v1.2.0/go.mod h1:3dlrS0iBaWKYVt2ZfA4cj48umJZ+cAEbR6/SjLA88I8=
|
||||
github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8=
|
||||
github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM=
|
||||
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM=
|
||||
@@ -103,8 +101,8 @@ github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfS
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
|
||||
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
|
||||
go.mongodb.org/mongo-driver/v2 v2.2.2 h1:9cYuS3fl1Xhqwpfazso10V7BHQD58kCgtzhfAmJYz9c=
|
||||
go.mongodb.org/mongo-driver/v2 v2.2.2/go.mod h1:qQkDMhCGWl3FN509DfdPd4GRBLU/41zqF/k8eTRceps=
|
||||
go.mongodb.org/mongo-driver/v2 v2.8.0 h1:CxWDGQYY8QQwNjAl/aq2sfWakdnWZynnqJ9F4DhHbP8=
|
||||
go.mongodb.org/mongo-driver/v2 v2.8.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
|
||||
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
|
||||
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
||||
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||
@@ -112,20 +110,20 @@ golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
|
||||
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
|
||||
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
|
||||
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
|
||||
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
|
||||
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
|
||||
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
||||
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w=
|
||||
golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
@@ -133,16 +131,16 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
|
||||
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
||||
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
|
||||
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
|
||||
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
|
||||
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
|
||||
@@ -19,7 +19,7 @@ func registerChannelRoutes(g *gin.RouterGroup) {
|
||||
}
|
||||
|
||||
func listChannels(c *gin.Context) {
|
||||
channels, err := services.ListChannels(auth.OrgID(c))
|
||||
channels, err := services.ListChannels(auth.InstanceID(c))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
@@ -37,7 +37,7 @@ func createChannel(c *gin.Context) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "name and type are required"})
|
||||
return
|
||||
}
|
||||
created, err := services.CreateChannel(auth.OrgID(c), &ch)
|
||||
created, err := services.CreateChannel(auth.InstanceID(c), &ch)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
@@ -73,7 +73,7 @@ func updateChannel(c *gin.Context) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "no fields to update"})
|
||||
return
|
||||
}
|
||||
if err := services.UpdateChannel(auth.OrgID(c), c.Param("id"), upd); err != nil {
|
||||
if err := services.UpdateChannel(auth.InstanceID(c), c.Param("id"), upd); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
@@ -81,7 +81,7 @@ func updateChannel(c *gin.Context) {
|
||||
}
|
||||
|
||||
func deleteChannel(c *gin.Context) {
|
||||
if err := services.DeleteChannel(auth.OrgID(c), c.Param("id")); err != nil {
|
||||
if err := services.DeleteChannel(auth.InstanceID(c), c.Param("id")); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
@@ -89,7 +89,7 @@ func deleteChannel(c *gin.Context) {
|
||||
}
|
||||
|
||||
func testChannel(c *gin.Context) {
|
||||
if err := services.TestChannel(auth.OrgID(c), c.Param("id")); err != nil {
|
||||
if err := services.TestChannel(auth.InstanceID(c), c.Param("id")); err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -13,9 +13,6 @@ import (
|
||||
"github.com/wwt/guac"
|
||||
)
|
||||
|
||||
|
||||
|
||||
|
||||
func consoleConnect(c *gin.Context) {
|
||||
var body struct {
|
||||
ServerID string `json:"server_id" binding:"required"`
|
||||
@@ -30,13 +27,13 @@ func consoleConnect(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
srv, err := services.GetServer(auth.OrgID(c), body.ServerID)
|
||||
srv, err := services.GetServer(auth.InstanceID(c), body.ServerID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "server not found"})
|
||||
return
|
||||
}
|
||||
|
||||
sess, err := services.CreateConsoleSession(auth.OrgID(c), body.ServerID, body.Protocol, body.KeyID, actorFromCtx(c), c.ClientIP())
|
||||
sess, err := services.CreateConsoleSession(auth.InstanceID(c), body.ServerID, body.Protocol, body.KeyID, actorFromCtx(c), c.ClientIP())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
@@ -48,20 +45,20 @@ func consoleConnect(c *gin.Context) {
|
||||
}
|
||||
|
||||
if (body.Protocol == "rdp" || body.Protocol == "vnc") && (body.RDPUsername != "" || body.RDPPassword != "") {
|
||||
if err := services.StashConsoleRDPCreds(auth.OrgID(c), sess.SessionID, body.RDPUsername, body.RDPPassword); err != nil {
|
||||
if err := services.StashConsoleRDPCreds(auth.InstanceID(c), sess.SessionID, body.RDPUsername, body.RDPPassword); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if body.Protocol == "ssh" {
|
||||
if err := services.SetConsoleSSHUser(auth.OrgID(c), sess.SessionID, body.SSHUsername); err != nil {
|
||||
if err := services.SetConsoleSSHUser(auth.InstanceID(c), sess.SessionID, body.SSHUsername); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
services.LogEvent(auth.OrgID(c), "console.opened", actorFromCtx(c), srv.ServerID, "",
|
||||
services.LogEvent(auth.InstanceID(c), "console.opened", actorFromCtx(c), srv.ServerID, "",
|
||||
"console session opened ("+body.Protocol+")")
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
@@ -71,8 +68,6 @@ func consoleConnect(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
|
||||
func queryIntDefault(r *http.Request, key string, def int) int {
|
||||
v, err := strconv.Atoi(r.URL.Query().Get(key))
|
||||
if err != nil || v <= 0 {
|
||||
@@ -81,7 +76,6 @@ func queryIntDefault(r *http.Request, key string, def int) int {
|
||||
return v
|
||||
}
|
||||
|
||||
|
||||
func consoleTunnel(c *gin.Context) {
|
||||
token := c.Query("token")
|
||||
sessionID, err := services.VerifySessionToken(token)
|
||||
@@ -89,36 +83,32 @@ func consoleTunnel(c *gin.Context) {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
|
||||
return
|
||||
}
|
||||
orgID := auth.OrgID(c)
|
||||
sess, err := services.GetConsoleSession(orgID, sessionID)
|
||||
instanceID := auth.InstanceID(c)
|
||||
sess, err := services.GetConsoleSession(instanceID, sessionID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "session not found"})
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
|
||||
if actor := actorFromCtx(c); actor != sess.User {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "session belongs to another user"})
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
if err := services.ConsumeSessionToken(orgID, sessionID); err != nil {
|
||||
if err := services.ConsumeSessionToken(instanceID, sessionID); err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "token already used"})
|
||||
return
|
||||
}
|
||||
|
||||
srv, err := services.GetServer(auth.OrgID(c), sess.ServerID)
|
||||
srv, err := services.GetServer(auth.InstanceID(c), sess.ServerID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "server not found"})
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
var privKey, passphrase string
|
||||
if sess.Protocol == "ssh" && sess.KeyID != "" {
|
||||
privKey, err = services.GetPrivateKey(auth.OrgID(c), sess.KeyID)
|
||||
privKey, err = services.GetPrivateKey(auth.InstanceID(c), sess.KeyID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "selected key has no private material"})
|
||||
return
|
||||
@@ -128,7 +118,7 @@ func consoleTunnel(c *gin.Context) {
|
||||
|
||||
var rdpUser, rdpPass string
|
||||
if sess.Protocol == "rdp" || sess.Protocol == "vnc" {
|
||||
rdpUser, rdpPass, err = services.ConsumeConsoleRDPCreds(orgID, sessionID)
|
||||
rdpUser, rdpPass, err = services.ConsumeConsoleRDPCreds(instanceID, sessionID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not load credentials"})
|
||||
return
|
||||
@@ -145,7 +135,6 @@ func consoleTunnel(c *gin.Context) {
|
||||
guacdAddr = "guacd:4822"
|
||||
}
|
||||
|
||||
|
||||
connect := func(r *http.Request) (guac.Tunnel, error) {
|
||||
config := guac.NewGuacamoleConfiguration()
|
||||
config.Protocol = gp.Protocol
|
||||
@@ -173,7 +162,7 @@ func consoleTunnel(c *gin.Context) {
|
||||
|
||||
wsServer := guac.NewWebsocketServer(connect)
|
||||
wsServer.OnDisconnect = func(id string, r *http.Request, t guac.Tunnel) {
|
||||
_ = services.EndConsoleSession(orgID, sessionID)
|
||||
_ = services.EndConsoleSession(instanceID, sessionID)
|
||||
}
|
||||
wsServer.ServeHTTP(c.Writer, c.Request)
|
||||
}
|
||||
|
||||
@@ -27,7 +27,6 @@ func RegisterRoutes(r *gin.Engine) {
|
||||
|
||||
r.GET("/api/secrets/:group/values", secretsReadAuth(), esoGetGroup)
|
||||
|
||||
|
||||
r.GET("/auth/bootstrap-status", auth.HandleBootstrapStatus)
|
||||
r.POST("/auth/bootstrap", auth.HandleBootstrap)
|
||||
r.POST("/auth/login", auth.HandleLocalLogin)
|
||||
@@ -36,7 +35,6 @@ func RegisterRoutes(r *gin.Engine) {
|
||||
r.GET("/auth/oidc/start", auth.HandleOIDCStart)
|
||||
r.GET("/auth/oidc/callback", auth.HandleOIDCCallback)
|
||||
|
||||
|
||||
apiGroup := r.Group("/api")
|
||||
apiGroup.Use(auth.Middleware())
|
||||
{
|
||||
@@ -85,21 +83,21 @@ func RegisterRoutes(r *gin.Engine) {
|
||||
registerMonitorRoutes(apiGroup)
|
||||
registerChannelRoutes(apiGroup)
|
||||
|
||||
org := apiGroup.Group("/org")
|
||||
org.Use(auth.RequireRole("owner", "admin"))
|
||||
instance := apiGroup.Group("/instance")
|
||||
instance.Use(auth.RequireRole("owner", "admin"))
|
||||
{
|
||||
org.GET("/users", listOrgUsers)
|
||||
org.POST("/users", createOrgUser)
|
||||
org.PUT("/users/:id/role", updateOrgUserRole)
|
||||
org.DELETE("/users/:id", deleteOrgUser)
|
||||
org.GET("/oidc", getOrgOIDC)
|
||||
org.PUT("/oidc", putOrgOIDC)
|
||||
instance.GET("/users", listInstanceUsers)
|
||||
instance.POST("/users", createInstanceUser)
|
||||
instance.PUT("/users/:id/role", updateInstanceUserRole)
|
||||
instance.DELETE("/users/:id", deleteInstanceUser)
|
||||
instance.GET("/oidc", getInstanceOIDC)
|
||||
instance.PUT("/oidc", putInstanceOIDC)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func listServers(c *gin.Context) {
|
||||
servers, err := services.ListServers(auth.OrgID(c))
|
||||
servers, err := services.ListServers(auth.InstanceID(c))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
@@ -108,7 +106,7 @@ func listServers(c *gin.Context) {
|
||||
}
|
||||
|
||||
func createServer(c *gin.Context) {
|
||||
s, token, err := services.CreateServer(auth.OrgID(c))
|
||||
s, token, err := services.CreateServer(auth.InstanceID(c))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
@@ -121,12 +119,12 @@ func createServer(c *gin.Context) {
|
||||
}
|
||||
|
||||
func newServer(c *gin.Context) {
|
||||
s, token, err := services.CreateServer(auth.OrgID(c))
|
||||
s, token, err := services.CreateServer(auth.InstanceID(c))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
services.LogEvent(auth.OrgID(c), "server.created", actorFromCtx(c), s.ServerID, "", "pre-registration token issued")
|
||||
services.LogEvent(auth.InstanceID(c), "server.created", actorFromCtx(c), s.ServerID, "", "pre-registration token issued")
|
||||
|
||||
giteaHost := os.Getenv("GITEA_HOST")
|
||||
if giteaHost == "" {
|
||||
@@ -155,15 +153,14 @@ func newServer(c *gin.Context) {
|
||||
|
||||
func getServer(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
s, err := services.GetServer(auth.OrgID(c), id)
|
||||
s, err := services.GetServer(auth.InstanceID(c), id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "server not found"})
|
||||
return
|
||||
}
|
||||
|
||||
assignments, _ := services.GetAssignmentsWithKeysForServer(auth.OrgID(c), id)
|
||||
assignments, _ := services.GetAssignmentsWithKeysForServer(auth.InstanceID(c), id)
|
||||
|
||||
|
||||
type serverResponse struct {
|
||||
*models.Server
|
||||
Keys interface{} `json:"keys"`
|
||||
@@ -176,8 +173,8 @@ func getServer(c *gin.Context) {
|
||||
|
||||
func deleteServer(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
s, _ := services.GetServer(auth.OrgID(c), id)
|
||||
if err := services.DeleteServer(auth.OrgID(c), id); err != nil {
|
||||
s, _ := services.GetServer(auth.InstanceID(c), id)
|
||||
if err := services.DeleteServer(auth.InstanceID(c), id); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
@@ -185,7 +182,7 @@ func deleteServer(c *gin.Context) {
|
||||
if s != nil {
|
||||
hostname = s.Hostname
|
||||
}
|
||||
services.LogEvent(auth.OrgID(c), "server.deleted", actorFromCtx(c), id, "", fmt.Sprintf("server %s deleted", hostname))
|
||||
services.LogEvent(auth.InstanceID(c), "server.deleted", actorFromCtx(c), id, "", fmt.Sprintf("server %s deleted", hostname))
|
||||
c.JSON(http.StatusOK, gin.H{"deleted": true})
|
||||
}
|
||||
|
||||
@@ -204,7 +201,7 @@ func generateKey(c *gin.Context) {
|
||||
body.Label = "generated"
|
||||
}
|
||||
|
||||
s, err := services.GetServer(auth.OrgID(c), id)
|
||||
s, err := services.GetServer(auth.InstanceID(c), id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "server not found"})
|
||||
return
|
||||
@@ -222,7 +219,7 @@ func generateKey(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
services.LogEvent(auth.OrgID(c), "key.generation_dispatched", actorFromCtx(c), s.ServerID, "", fmt.Sprintf("key generation dispatched (label=%s type=%s)", body.Label, body.KeyType))
|
||||
services.LogEvent(auth.InstanceID(c), "key.generation_dispatched", actorFromCtx(c), s.ServerID, "", fmt.Sprintf("key generation dispatched (label=%s type=%s)", body.Label, body.KeyType))
|
||||
c.JSON(http.StatusAccepted, gin.H{
|
||||
"message": "key generation command sent to agent",
|
||||
"command_id": cmdID,
|
||||
@@ -231,7 +228,7 @@ func generateKey(c *gin.Context) {
|
||||
}
|
||||
|
||||
func listKeys(c *gin.Context) {
|
||||
keys, err := services.ListKeys(auth.OrgID(c))
|
||||
keys, err := services.ListKeys(auth.InstanceID(c))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
@@ -251,18 +248,18 @@ func createKey(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
key, err := services.CreateKey(auth.OrgID(c), body.Label, body.PublicKey, "uploaded", "", body.PrivateKey, body.Passphrase)
|
||||
key, err := services.CreateKey(auth.InstanceID(c), body.Label, body.PublicKey, "uploaded", "", body.PrivateKey, body.Passphrase)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
services.LogEvent(auth.OrgID(c), "key.uploaded", actorFromCtx(c), "", key.KeyID, fmt.Sprintf("key '%s' uploaded", key.Label))
|
||||
services.LogEvent(auth.InstanceID(c), "key.uploaded", actorFromCtx(c), "", key.KeyID, fmt.Sprintf("key '%s' uploaded", key.Label))
|
||||
c.JSON(http.StatusCreated, key)
|
||||
}
|
||||
|
||||
func getPrivateKey(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
plaintext, err := services.GetPrivateKey(auth.OrgID(c), id)
|
||||
plaintext, err := services.GetPrivateKey(auth.InstanceID(c), id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
return
|
||||
@@ -272,13 +269,13 @@ func getPrivateKey(c *gin.Context) {
|
||||
|
||||
func getKey(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
key, err := services.GetKey(auth.OrgID(c), id)
|
||||
key, err := services.GetKey(auth.InstanceID(c), id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "key not found"})
|
||||
return
|
||||
}
|
||||
|
||||
assignments, _ := services.GetAssignmentsWithServers(auth.OrgID(c), id)
|
||||
assignments, _ := services.GetAssignmentsWithServers(auth.InstanceID(c), id)
|
||||
|
||||
type keyResponse struct {
|
||||
*models.Key
|
||||
@@ -292,8 +289,8 @@ func getKey(c *gin.Context) {
|
||||
|
||||
func deleteKey(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
k, _ := services.GetKey(auth.OrgID(c), id)
|
||||
if err := services.DeleteKey(auth.OrgID(c), id); err != nil {
|
||||
k, _ := services.GetKey(auth.InstanceID(c), id)
|
||||
if err := services.DeleteKey(auth.InstanceID(c), id); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
@@ -301,7 +298,7 @@ func deleteKey(c *gin.Context) {
|
||||
if k != nil {
|
||||
label = k.Label
|
||||
}
|
||||
services.LogEvent(auth.OrgID(c), "key.deleted", actorFromCtx(c), "", id, fmt.Sprintf("key '%s' deleted", label))
|
||||
services.LogEvent(auth.InstanceID(c), "key.deleted", actorFromCtx(c), "", id, fmt.Sprintf("key '%s' deleted", label))
|
||||
c.JSON(http.StatusOK, gin.H{"deleted": true})
|
||||
}
|
||||
|
||||
@@ -315,12 +312,12 @@ func assignKey(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
a, err := services.AssignKey(auth.OrgID(c), keyID, body.ServerID)
|
||||
a, err := services.AssignKey(auth.InstanceID(c), keyID, body.ServerID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
services.LogEvent(auth.OrgID(c), "key.assigned", actorFromCtx(c), body.ServerID, keyID, fmt.Sprintf("key %s assigned to server %s", keyID, body.ServerID))
|
||||
services.LogEvent(auth.InstanceID(c), "key.assigned", actorFromCtx(c), body.ServerID, keyID, fmt.Sprintf("key %s assigned to server %s", keyID, body.ServerID))
|
||||
c.JSON(http.StatusCreated, a)
|
||||
}
|
||||
|
||||
@@ -328,11 +325,11 @@ func revokeAssignment(c *gin.Context) {
|
||||
keyID := c.Param("id")
|
||||
serverID := c.Param("serverId")
|
||||
|
||||
if err := services.RevokeAssignment(auth.OrgID(c), keyID, serverID); err != nil {
|
||||
if err := services.RevokeAssignment(auth.InstanceID(c), keyID, serverID); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
services.LogEvent(auth.OrgID(c), "key.revoked", actorFromCtx(c), serverID, keyID, fmt.Sprintf("key %s revoked from server %s", keyID, serverID))
|
||||
services.LogEvent(auth.InstanceID(c), "key.revoked", actorFromCtx(c), serverID, keyID, fmt.Sprintf("key %s revoked from server %s", keyID, serverID))
|
||||
c.JSON(http.StatusOK, gin.H{"revoked": true})
|
||||
}
|
||||
|
||||
@@ -347,7 +344,7 @@ func getLatestAgentVersion(c *gin.Context) {
|
||||
|
||||
func updateAgent(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
s, err := services.GetServer(auth.OrgID(c), id)
|
||||
s, err := services.GetServer(auth.InstanceID(c), id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "server not found"})
|
||||
return
|
||||
@@ -358,7 +355,7 @@ func updateAgent(c *gin.Context) {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
services.LogEvent(auth.OrgID(c), "agent.update_dispatched", actorFromCtx(c), s.ServerID, "", fmt.Sprintf("agent update dispatched to %s (version %s)", s.Hostname, version))
|
||||
services.LogEvent(auth.InstanceID(c), "agent.update_dispatched", actorFromCtx(c), s.ServerID, "", fmt.Sprintf("agent update dispatched to %s (version %s)", s.Hostname, version))
|
||||
c.JSON(http.StatusAccepted, gin.H{
|
||||
"message": "update command sent to agent",
|
||||
"version": version,
|
||||
@@ -367,7 +364,7 @@ func updateAgent(c *gin.Context) {
|
||||
|
||||
func applyUpdates(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
s, err := services.GetServer(auth.OrgID(c), id)
|
||||
s, err := services.GetServer(auth.InstanceID(c), id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "server not found"})
|
||||
return
|
||||
@@ -377,7 +374,7 @@ func applyUpdates(c *gin.Context) {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
services.LogEvent(auth.OrgID(c), "updates.applied", actorFromCtx(c), s.ServerID, "", fmt.Sprintf("package update command dispatched to %s", s.Hostname))
|
||||
services.LogEvent(auth.InstanceID(c), "updates.applied", actorFromCtx(c), s.ServerID, "", fmt.Sprintf("package update command dispatched to %s", s.Hostname))
|
||||
c.JSON(http.StatusAccepted, gin.H{"message": "apply updates command sent to agent"})
|
||||
}
|
||||
|
||||
@@ -444,7 +441,7 @@ func listAuditEvents(c *gin.Context) {
|
||||
limit = n
|
||||
}
|
||||
}
|
||||
events, err := services.ListAuditEvents(auth.OrgID(c), limit)
|
||||
events, err := services.ListAuditEvents(auth.InstanceID(c), limit)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
@@ -453,7 +450,7 @@ func listAuditEvents(c *gin.Context) {
|
||||
}
|
||||
|
||||
func getSettings(c *gin.Context) {
|
||||
s, err := services.GetSettings(auth.OrgID(c))
|
||||
s, err := services.GetSettings(auth.InstanceID(c))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
@@ -471,11 +468,11 @@ func saveSettings(c *gin.Context) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := services.SaveSettings(auth.OrgID(c), body.Alerts, body.Email, body.WorkflowLogRetentionDays); err != nil {
|
||||
if err := services.SaveSettings(auth.InstanceID(c), body.Alerts, body.Email, body.WorkflowLogRetentionDays); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
services.LogEvent(auth.OrgID(c), "settings.updated", actorFromCtx(c), "", "", "alert settings updated")
|
||||
services.LogEvent(auth.InstanceID(c), "settings.updated", actorFromCtx(c), "", "", "alert settings updated")
|
||||
c.JSON(http.StatusOK, gin.H{"saved": true})
|
||||
}
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ func handleInstallScriptWindows(c *gin.Context) {
|
||||
if giteaHost == "" {
|
||||
giteaHost = "gitea.example.com"
|
||||
}
|
||||
|
||||
|
||||
grpcHost := os.Getenv("GRPC_HOST")
|
||||
|
||||
script := fmt.Sprintf(
|
||||
@@ -50,9 +50,6 @@ func handleInstallScriptWindows(c *gin.Context) {
|
||||
c.String(http.StatusOK, script)
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
func handleUpdateScriptWindows(c *gin.Context) {
|
||||
giteaHost := os.Getenv("GITEA_HOST")
|
||||
if giteaHost == "" {
|
||||
|
||||
@@ -22,7 +22,7 @@ func registerMonitorRoutes(g *gin.RouterGroup) {
|
||||
}
|
||||
|
||||
func listMonitors(c *gin.Context) {
|
||||
monitors, err := services.ListMonitors(auth.OrgID(c))
|
||||
monitors, err := services.ListMonitors(auth.InstanceID(c))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
@@ -40,7 +40,7 @@ func createMonitor(c *gin.Context) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "name and type are required"})
|
||||
return
|
||||
}
|
||||
created, err := services.CreateMonitor(auth.OrgID(c), &m)
|
||||
created, err := services.CreateMonitor(auth.InstanceID(c), &m)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
@@ -49,7 +49,7 @@ func createMonitor(c *gin.Context) {
|
||||
}
|
||||
|
||||
func getMonitor(c *gin.Context) {
|
||||
m, err := services.GetMonitor(auth.OrgID(c), c.Param("id"))
|
||||
m, err := services.GetMonitor(auth.InstanceID(c), c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
@@ -105,7 +105,7 @@ func updateMonitor(c *gin.Context) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "no fields to update"})
|
||||
return
|
||||
}
|
||||
if err := services.UpdateMonitor(auth.OrgID(c), c.Param("id"), upd); err != nil {
|
||||
if err := services.UpdateMonitor(auth.InstanceID(c), c.Param("id"), upd); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
@@ -113,7 +113,7 @@ func updateMonitor(c *gin.Context) {
|
||||
}
|
||||
|
||||
func deleteMonitor(c *gin.Context) {
|
||||
if err := services.DeleteMonitor(auth.OrgID(c), c.Param("id")); err != nil {
|
||||
if err := services.DeleteMonitor(auth.InstanceID(c), c.Param("id")); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
@@ -121,7 +121,7 @@ func deleteMonitor(c *gin.Context) {
|
||||
}
|
||||
|
||||
func getMonitorIncidents(c *gin.Context) {
|
||||
m, err := services.GetMonitor(auth.OrgID(c), c.Param("id"))
|
||||
m, err := services.GetMonitor(auth.InstanceID(c), c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
@@ -130,7 +130,7 @@ func getMonitorIncidents(c *gin.Context) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "monitor not found"})
|
||||
return
|
||||
}
|
||||
incidents, err := services.ListIncidents(auth.OrgID(c), c.Param("id"), 50)
|
||||
incidents, err := services.ListIncidents(auth.InstanceID(c), c.Param("id"), 50)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
@@ -139,7 +139,7 @@ func getMonitorIncidents(c *gin.Context) {
|
||||
}
|
||||
|
||||
func getMonitorUptime(c *gin.Context) {
|
||||
m, err := services.GetMonitor(auth.OrgID(c), c.Param("id"))
|
||||
m, err := services.GetMonitor(auth.InstanceID(c), c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
@@ -149,7 +149,7 @@ func getMonitorUptime(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
since := time.Now().Add(-30 * 24 * time.Hour)
|
||||
rollups, err := services.UptimeRollups(auth.OrgID(c), c.Param("id"), since)
|
||||
rollups, err := services.UptimeRollups(auth.InstanceID(c), c.Param("id"), since)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
|
||||
+19
-23
@@ -10,8 +10,8 @@ import (
|
||||
"github.com/mrhid6/vantage/server/internal/services"
|
||||
)
|
||||
|
||||
func listOrgUsers(c *gin.Context) {
|
||||
users, err := services.ListUsers(auth.OrgID(c))
|
||||
func listInstanceUsers(c *gin.Context) {
|
||||
users, err := services.ListUsers(auth.InstanceID(c))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
@@ -19,14 +19,11 @@ func listOrgUsers(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, users)
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
func actorMayGrantOwner(c *gin.Context) bool {
|
||||
return auth.Role(c) == models.RoleOwner
|
||||
}
|
||||
|
||||
func createOrgUser(c *gin.Context) {
|
||||
func createInstanceUser(c *gin.Context) {
|
||||
var body struct {
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
@@ -47,7 +44,7 @@ func createOrgUser(c *gin.Context) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "only an owner can create another owner"})
|
||||
return
|
||||
}
|
||||
u, err := services.CreateUser(auth.OrgID(c), body.Email, body.Password, body.Role, "local")
|
||||
u, err := services.CreateUser(auth.InstanceID(c), body.Email, body.Password, body.Role, "local")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
@@ -55,7 +52,7 @@ func createOrgUser(c *gin.Context) {
|
||||
c.JSON(http.StatusCreated, u)
|
||||
}
|
||||
|
||||
func updateOrgUserRole(c *gin.Context) {
|
||||
func updateInstanceUserRole(c *gin.Context) {
|
||||
var body struct {
|
||||
Role string `json:"role"`
|
||||
}
|
||||
@@ -68,12 +65,12 @@ func updateOrgUserRole(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
orgID, targetID := auth.OrgID(c), c.Param("id")
|
||||
instanceID, targetID := auth.InstanceID(c), c.Param("id")
|
||||
if targetID == auth.UserID(c) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "you cannot change your own role"})
|
||||
return
|
||||
}
|
||||
target, err := services.GetUserInOrg(orgID, targetID)
|
||||
target, err := services.GetUserInInstance(instanceID, targetID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
|
||||
return
|
||||
@@ -83,20 +80,20 @@ func updateOrgUserRole(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if err := services.UpdateUserRole(orgID, targetID, body.Role); err != nil {
|
||||
if err := services.UpdateUserRole(instanceID, targetID, body.Role); err != nil {
|
||||
c.JSON(orgUserErrStatus(err), gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
func deleteOrgUser(c *gin.Context) {
|
||||
orgID, targetID := auth.OrgID(c), c.Param("id")
|
||||
func deleteInstanceUser(c *gin.Context) {
|
||||
instanceID, targetID := auth.InstanceID(c), c.Param("id")
|
||||
if targetID == auth.UserID(c) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "you cannot remove your own account"})
|
||||
return
|
||||
}
|
||||
target, err := services.GetUserInOrg(orgID, targetID)
|
||||
target, err := services.GetUserInInstance(instanceID, targetID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
|
||||
return
|
||||
@@ -106,7 +103,7 @@ func deleteOrgUser(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if err := services.DeleteUser(orgID, targetID); err != nil {
|
||||
if err := services.DeleteUser(instanceID, targetID); err != nil {
|
||||
c.JSON(orgUserErrStatus(err), gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
@@ -120,16 +117,15 @@ func orgUserErrStatus(err error) int {
|
||||
return http.StatusInternalServerError
|
||||
}
|
||||
|
||||
func getOrgOIDC(c *gin.Context) {
|
||||
cfg, err := services.GetOrgOIDC(auth.OrgID(c))
|
||||
func getInstanceOIDC(c *gin.Context) {
|
||||
cfg, err := services.GetInstanceOIDC(auth.InstanceID(c))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"enabled": false, "client_secret_set": false})
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"org_id": cfg.OrgID,
|
||||
"instance_id": cfg.InstanceID,
|
||||
"issuer": cfg.Issuer,
|
||||
"client_id": cfg.ClientID,
|
||||
"enabled": cfg.Enabled,
|
||||
@@ -138,7 +134,7 @@ func getOrgOIDC(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
func putOrgOIDC(c *gin.Context) {
|
||||
func putInstanceOIDC(c *gin.Context) {
|
||||
var body struct {
|
||||
Issuer string `json:"issuer"`
|
||||
ClientID string `json:"client_id"`
|
||||
@@ -149,11 +145,11 @@ func putOrgOIDC(c *gin.Context) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := services.SaveOrgOIDC(auth.OrgID(c), body.Issuer, body.ClientID, body.ClientSecret, body.Enabled); err != nil {
|
||||
if err := services.SaveOrgOIDC(auth.InstanceID(c), body.Issuer, body.ClientID, body.ClientSecret, body.Enabled); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
auth.EvictOIDCProvider(auth.OrgID(c))
|
||||
auth.EvictOIDCProvider(auth.InstanceID(c))
|
||||
c.JSON(http.StatusOK, gin.H{"saved": true})
|
||||
}
|
||||
|
||||
@@ -11,22 +11,13 @@ import (
|
||||
"github.com/mrhid6/vantage/server/internal/services"
|
||||
)
|
||||
|
||||
|
||||
|
||||
var groupNamePattern = regexp.MustCompile(`^[A-Za-z0-9._-]+$`)
|
||||
|
||||
func validName(s string) bool {
|
||||
return s != "" && len(s) <= 128 && groupNamePattern.MatchString(s)
|
||||
}
|
||||
|
||||
|
||||
const ctxSecretsOrgKey = "km_secrets_org"
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
const ctxSecretsInstanceKey = "km_secrets_instance"
|
||||
|
||||
func secretsReadAuth() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
@@ -36,29 +27,26 @@ func secretsReadAuth() gin.HandlerFunc {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "missing bearer token"})
|
||||
return
|
||||
}
|
||||
orgID, ok := services.ResolveSecretsReadToken(authHeader[len(prefix):])
|
||||
instanceID, ok := services.ResolveSecretsReadToken(authHeader[len(prefix):])
|
||||
if !ok {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
|
||||
return
|
||||
}
|
||||
c.Set(ctxSecretsOrgKey, orgID)
|
||||
c.Set(ctxSecretsInstanceKey, instanceID)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
func esoGetGroup(c *gin.Context) {
|
||||
group := c.Param("group")
|
||||
|
||||
orgID := c.GetString(ctxSecretsOrgKey)
|
||||
if orgID == "" {
|
||||
|
||||
|
||||
instanceID := c.GetString(ctxSecretsInstanceKey)
|
||||
if instanceID == "" {
|
||||
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
|
||||
return
|
||||
}
|
||||
values, err := services.GetSecretGroupDecrypted(orgID, group)
|
||||
values, err := services.GetSecretGroupDecrypted(instanceID, group)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "store error"})
|
||||
return
|
||||
@@ -71,7 +59,7 @@ func esoGetGroup(c *gin.Context) {
|
||||
}
|
||||
|
||||
func listSecretGroups(c *gin.Context) {
|
||||
groups, err := services.ListSecretGroups(auth.OrgID(c))
|
||||
groups, err := services.ListSecretGroups(auth.InstanceID(c))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
@@ -79,8 +67,6 @@ func listSecretGroups(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, groups)
|
||||
}
|
||||
|
||||
|
||||
|
||||
func createSecretGroup(c *gin.Context) {
|
||||
var body struct {
|
||||
Group string `json:"group" binding:"required"`
|
||||
@@ -104,17 +90,17 @@ func createSecretGroup(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := services.UpsertSecrets(auth.OrgID(c), body.Group, body.Values); err != nil {
|
||||
if err := services.UpsertSecrets(auth.InstanceID(c), body.Group, body.Values); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
services.LogEvent(auth.OrgID(c), "secret.updated", actorFromCtx(c), "", "", fmt.Sprintf("group '%s' created with keys: %s", body.Group, strings.Join(services.SortedKeys(body.Values), ", ")))
|
||||
services.LogEvent(auth.InstanceID(c), "secret.updated", actorFromCtx(c), "", "", fmt.Sprintf("group '%s' created with keys: %s", body.Group, strings.Join(services.SortedKeys(body.Values), ", ")))
|
||||
c.JSON(http.StatusCreated, gin.H{"group": body.Group})
|
||||
}
|
||||
|
||||
func getSecretGroup(c *gin.Context) {
|
||||
group := c.Param("group")
|
||||
secrets, err := services.GetSecretGroup(auth.OrgID(c), group)
|
||||
secrets, err := services.GetSecretGroup(auth.InstanceID(c), group)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
@@ -126,7 +112,6 @@ func getSecretGroup(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"group": group, "secrets": secrets})
|
||||
}
|
||||
|
||||
|
||||
func putSecretGroup(c *gin.Context) {
|
||||
group := c.Param("group")
|
||||
if !validName(group) {
|
||||
@@ -148,11 +133,11 @@ func putSecretGroup(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := services.UpsertSecrets(auth.OrgID(c), group, values); err != nil {
|
||||
if err := services.UpsertSecrets(auth.InstanceID(c), group, values); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
services.LogEvent(auth.OrgID(c), "secret.updated", actorFromCtx(c), "", "", fmt.Sprintf("group '%s' keys updated: %s", group, strings.Join(services.SortedKeys(values), ", ")))
|
||||
services.LogEvent(auth.InstanceID(c), "secret.updated", actorFromCtx(c), "", "", fmt.Sprintf("group '%s' keys updated: %s", group, strings.Join(services.SortedKeys(values), ", ")))
|
||||
c.JSON(http.StatusOK, gin.H{"saved": true})
|
||||
}
|
||||
|
||||
@@ -165,42 +150,42 @@ func revealSecret(c *gin.Context) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
value, err := services.RevealSecret(auth.OrgID(c), group, body.Key)
|
||||
value, err := services.RevealSecret(auth.InstanceID(c), group, body.Key)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
services.LogEvent(auth.OrgID(c), "secret.revealed", actorFromCtx(c), "", "", fmt.Sprintf("value of '%s/%s' revealed", group, body.Key))
|
||||
services.LogEvent(auth.InstanceID(c), "secret.revealed", actorFromCtx(c), "", "", fmt.Sprintf("value of '%s/%s' revealed", group, body.Key))
|
||||
c.JSON(http.StatusOK, gin.H{"value": value})
|
||||
}
|
||||
|
||||
func deleteSecretKey(c *gin.Context) {
|
||||
group := c.Param("group")
|
||||
key := c.Param("key")
|
||||
if err := services.DeleteSecret(auth.OrgID(c), group, key); err != nil {
|
||||
if err := services.DeleteSecret(auth.InstanceID(c), group, key); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
services.LogEvent(auth.OrgID(c), "secret.deleted", actorFromCtx(c), "", "", fmt.Sprintf("key '%s' deleted from group '%s'", key, group))
|
||||
services.LogEvent(auth.InstanceID(c), "secret.deleted", actorFromCtx(c), "", "", fmt.Sprintf("key '%s' deleted from group '%s'", key, group))
|
||||
c.JSON(http.StatusOK, gin.H{"deleted": true})
|
||||
}
|
||||
|
||||
func deleteSecretGroup(c *gin.Context) {
|
||||
group := c.Param("group")
|
||||
if err := services.DeleteSecretGroup(auth.OrgID(c), group); err != nil {
|
||||
if err := services.DeleteSecretGroup(auth.InstanceID(c), group); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
services.LogEvent(auth.OrgID(c), "secretgroup.deleted", actorFromCtx(c), "", "", fmt.Sprintf("group '%s' deleted", group))
|
||||
services.LogEvent(auth.InstanceID(c), "secretgroup.deleted", actorFromCtx(c), "", "", fmt.Sprintf("group '%s' deleted", group))
|
||||
c.JSON(http.StatusOK, gin.H{"deleted": true})
|
||||
}
|
||||
|
||||
func rotateSecretsToken(c *gin.Context) {
|
||||
token, err := services.RotateSecretsReadToken(auth.OrgID(c))
|
||||
token, err := services.RotateSecretsReadToken(auth.InstanceID(c))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
services.LogEvent(auth.OrgID(c), "secrets.token_rotated", actorFromCtx(c), "", "", "ESO read token rotated")
|
||||
services.LogEvent(auth.InstanceID(c), "secrets.token_rotated", actorFromCtx(c), "", "", "ESO read token rotated")
|
||||
c.JSON(http.StatusOK, gin.H{"token": token})
|
||||
}
|
||||
|
||||
@@ -81,7 +81,7 @@ func streamServerRunLog(c *gin.Context) {
|
||||
sendNew := func() {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
if _, err := f.Seek(offset, 0); err != nil {
|
||||
@@ -94,7 +94,7 @@ func streamServerRunLog(c *gin.Context) {
|
||||
break
|
||||
}
|
||||
offset += int64(n)
|
||||
|
||||
|
||||
for _, line := range splitSSE(buf[:n]) {
|
||||
_, _ = c.Writer.WriteString("data: " + line + "\n")
|
||||
}
|
||||
@@ -106,11 +106,11 @@ func streamServerRunLog(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
ticker := time.NewTicker(500 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
orgID := auth.OrgID(c)
|
||||
instanceID := auth.InstanceID(c)
|
||||
for {
|
||||
sendNew()
|
||||
if serverRunTerminal(orgID, runID, serverID) {
|
||||
sendNew()
|
||||
if serverRunTerminal(instanceID, runID, serverID) {
|
||||
sendNew()
|
||||
_, _ = c.Writer.WriteString("event: done\ndata: end\n\n")
|
||||
flusher.Flush()
|
||||
return
|
||||
@@ -123,9 +123,8 @@ func streamServerRunLog(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func serverRunTerminal(orgID, runID, serverID string) bool {
|
||||
r, err := services.GetRun(orgID, runID)
|
||||
func serverRunTerminal(instanceID, runID, serverID string) bool {
|
||||
r, err := services.GetRun(instanceID, runID)
|
||||
if err != nil {
|
||||
return true
|
||||
}
|
||||
@@ -141,15 +140,13 @@ func serverRunTerminal(orgID, runID, serverID string) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
|
||||
func splitSSE(b []byte) []string {
|
||||
s := strings.ReplaceAll(string(b), "\r", "")
|
||||
return strings.Split(s, "\n")
|
||||
}
|
||||
|
||||
func listSteps(c *gin.Context) {
|
||||
steps, err := services.ListSteps(auth.OrgID(c))
|
||||
steps, err := services.ListSteps(auth.InstanceID(c))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
@@ -158,7 +155,7 @@ func listSteps(c *gin.Context) {
|
||||
}
|
||||
|
||||
func stepUsage(c *gin.Context) {
|
||||
counts, err := services.StepUsageCounts(auth.OrgID(c))
|
||||
counts, err := services.StepUsageCounts(auth.InstanceID(c))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
@@ -172,12 +169,12 @@ func createStep(c *gin.Context) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
out, err := services.CreateStep(auth.OrgID(c), s)
|
||||
out, err := services.CreateStep(auth.InstanceID(c), s)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
services.LogEvent(auth.OrgID(c), "workflow.step_created", actorFromCtx(c), "", out.StepID, fmt.Sprintf("step '%s' created", out.Name))
|
||||
services.LogEvent(auth.InstanceID(c), "workflow.step_created", actorFromCtx(c), "", out.StepID, fmt.Sprintf("step '%s' created", out.Name))
|
||||
c.JSON(http.StatusCreated, out)
|
||||
}
|
||||
|
||||
@@ -187,25 +184,25 @@ func updateStep(c *gin.Context) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := services.UpdateStep(auth.OrgID(c), c.Param("id"), s); err != nil {
|
||||
if err := services.UpdateStep(auth.InstanceID(c), c.Param("id"), s); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
services.LogEvent(auth.OrgID(c), "workflow.step_updated", actorFromCtx(c), "", c.Param("id"), "step updated")
|
||||
services.LogEvent(auth.InstanceID(c), "workflow.step_updated", actorFromCtx(c), "", c.Param("id"), "step updated")
|
||||
c.JSON(http.StatusOK, gin.H{"updated": true})
|
||||
}
|
||||
|
||||
func deleteStep(c *gin.Context) {
|
||||
if err := services.DeleteStep(auth.OrgID(c), c.Param("id")); err != nil {
|
||||
if err := services.DeleteStep(auth.InstanceID(c), c.Param("id")); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
services.LogEvent(auth.OrgID(c), "workflow.step_deleted", actorFromCtx(c), "", c.Param("id"), "step deleted")
|
||||
services.LogEvent(auth.InstanceID(c), "workflow.step_deleted", actorFromCtx(c), "", c.Param("id"), "step deleted")
|
||||
c.JSON(http.StatusOK, gin.H{"deleted": true})
|
||||
}
|
||||
|
||||
func exportStep(c *gin.Context) {
|
||||
b, err := services.ExportStep(auth.OrgID(c), c.Param("id"))
|
||||
b, err := services.ExportStep(auth.InstanceID(c), c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
return
|
||||
@@ -215,16 +212,16 @@ func exportStep(c *gin.Context) {
|
||||
}
|
||||
|
||||
func seedDefaults(c *gin.Context) {
|
||||
created, updated, err := services.SeedDefaultSteps(auth.OrgID(c))
|
||||
created, updated, err := services.SeedDefaultSteps(auth.InstanceID(c))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
services.LogEvent(auth.OrgID(c), "workflow.defaults_synced", actorFromCtx(c), "", "", fmt.Sprintf("default steps synced: %d created, %d updated", created, updated))
|
||||
services.LogEvent(auth.InstanceID(c), "workflow.defaults_synced", actorFromCtx(c), "", "", fmt.Sprintf("default steps synced: %d created, %d updated", created, updated))
|
||||
c.JSON(http.StatusOK, gin.H{"created": created, "updated": updated})
|
||||
}
|
||||
|
||||
const maxStepBodyBytes = 1 << 20
|
||||
const maxStepBodyBytes = 1 << 20
|
||||
|
||||
func importStep(c *gin.Context) {
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxStepBodyBytes)
|
||||
@@ -233,12 +230,12 @@ func importStep(c *gin.Context) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
out, err := services.ImportStepToLibrary(auth.OrgID(c), body)
|
||||
out, err := services.ImportStepToLibrary(auth.InstanceID(c), body)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
services.LogEvent(auth.OrgID(c), "workflow.step_imported", actorFromCtx(c), "", out.StepID, fmt.Sprintf("step '%s' imported", out.Name))
|
||||
services.LogEvent(auth.InstanceID(c), "workflow.step_imported", actorFromCtx(c), "", out.StepID, fmt.Sprintf("step '%s' imported", out.Name))
|
||||
c.JSON(http.StatusCreated, out)
|
||||
}
|
||||
|
||||
@@ -258,7 +255,7 @@ func parseStep(c *gin.Context) {
|
||||
}
|
||||
|
||||
func listWorkflows(c *gin.Context) {
|
||||
wfs, err := services.ListWorkflows(auth.OrgID(c))
|
||||
wfs, err := services.ListWorkflows(auth.InstanceID(c))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
@@ -272,17 +269,17 @@ func createWorkflow(c *gin.Context) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
out, err := services.CreateWorkflow(auth.OrgID(c), w)
|
||||
out, err := services.CreateWorkflow(auth.InstanceID(c), w)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
services.LogEvent(auth.OrgID(c), "workflow.created", actorFromCtx(c), "", out.WorkflowID, fmt.Sprintf("workflow '%s' created", out.Name))
|
||||
services.LogEvent(auth.InstanceID(c), "workflow.created", actorFromCtx(c), "", out.WorkflowID, fmt.Sprintf("workflow '%s' created", out.Name))
|
||||
c.JSON(http.StatusCreated, out)
|
||||
}
|
||||
|
||||
func getWorkflow(c *gin.Context) {
|
||||
w, err := services.GetWorkflow(auth.OrgID(c), c.Param("id"))
|
||||
w, err := services.GetWorkflow(auth.InstanceID(c), c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
return
|
||||
@@ -296,12 +293,12 @@ func updateWorkflow(c *gin.Context) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := services.UpdateWorkflow(auth.OrgID(c), c.Param("id"), w); err != nil {
|
||||
if err := services.UpdateWorkflow(auth.InstanceID(c), c.Param("id"), w); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
services.LogEvent(auth.OrgID(c), "workflow.updated", actorFromCtx(c), "", c.Param("id"), "workflow updated")
|
||||
updated, err := services.GetWorkflow(auth.OrgID(c), c.Param("id"))
|
||||
services.LogEvent(auth.InstanceID(c), "workflow.updated", actorFromCtx(c), "", c.Param("id"), "workflow updated")
|
||||
updated, err := services.GetWorkflow(auth.InstanceID(c), c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
@@ -310,21 +307,21 @@ func updateWorkflow(c *gin.Context) {
|
||||
}
|
||||
|
||||
func deleteWorkflow(c *gin.Context) {
|
||||
if err := services.DeleteWorkflow(auth.OrgID(c), c.Param("id")); err != nil {
|
||||
if err := services.DeleteWorkflow(auth.InstanceID(c), c.Param("id")); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
services.LogEvent(auth.OrgID(c), "workflow.deleted", actorFromCtx(c), "", c.Param("id"), "workflow deleted")
|
||||
services.LogEvent(auth.InstanceID(c), "workflow.deleted", actorFromCtx(c), "", c.Param("id"), "workflow deleted")
|
||||
c.JSON(http.StatusOK, gin.H{"deleted": true})
|
||||
}
|
||||
|
||||
func runWorkflow(c *gin.Context) {
|
||||
runID, err := services.TriggerWorkflow(auth.OrgID(c), c.Param("id"), actorFromCtx(c))
|
||||
runID, err := services.TriggerWorkflow(auth.InstanceID(c), c.Param("id"), actorFromCtx(c))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
services.LogEvent(auth.OrgID(c), "workflow.run_triggered", actorFromCtx(c), "", c.Param("id"), fmt.Sprintf("run %s triggered", runID))
|
||||
services.LogEvent(auth.InstanceID(c), "workflow.run_triggered", actorFromCtx(c), "", c.Param("id"), fmt.Sprintf("run %s triggered", runID))
|
||||
c.JSON(http.StatusAccepted, gin.H{"run_id": runID})
|
||||
}
|
||||
|
||||
@@ -335,7 +332,7 @@ func listWorkflowRuns(c *gin.Context) {
|
||||
limit = n
|
||||
}
|
||||
}
|
||||
runs, err := services.ListRuns(auth.OrgID(c), c.Param("id"), limit)
|
||||
runs, err := services.ListRuns(auth.InstanceID(c), c.Param("id"), limit)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
@@ -344,7 +341,7 @@ func listWorkflowRuns(c *gin.Context) {
|
||||
}
|
||||
|
||||
func getRun(c *gin.Context) {
|
||||
r, err := services.GetRun(auth.OrgID(c), c.Param("runId"))
|
||||
r, err := services.GetRun(auth.InstanceID(c), c.Param("runId"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
return
|
||||
@@ -353,10 +350,10 @@ func getRun(c *gin.Context) {
|
||||
}
|
||||
|
||||
func cancelRun(c *gin.Context) {
|
||||
if err := services.CancelRun(auth.OrgID(c), c.Param("runId")); err != nil {
|
||||
if err := services.CancelRun(auth.InstanceID(c), c.Param("runId")); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
services.LogEvent(auth.OrgID(c), "workflow.run_cancelled", actorFromCtx(c), "", c.Param("runId"), "run cancelled")
|
||||
services.LogEvent(auth.InstanceID(c), "workflow.run_cancelled", actorFromCtx(c), "", c.Param("runId"), "run cancelled")
|
||||
c.JSON(http.StatusOK, gin.H{"cancelled": true})
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
)
|
||||
|
||||
type cachedOrg struct {
|
||||
org *models.Org
|
||||
org *models.Instance
|
||||
at time.Time
|
||||
}
|
||||
|
||||
@@ -23,9 +23,6 @@ var (
|
||||
|
||||
const orgCacheTTL = 60 * time.Second
|
||||
|
||||
|
||||
|
||||
|
||||
func appRootLabel() string {
|
||||
if v := os.Getenv("APP_ROOT_LABEL"); v != "" {
|
||||
return strings.ToLower(v)
|
||||
@@ -33,15 +30,13 @@ func appRootLabel() string {
|
||||
return "vantage"
|
||||
}
|
||||
|
||||
|
||||
|
||||
func hostSlug(host string) string {
|
||||
host = strings.ToLower(host)
|
||||
if i := strings.IndexByte(host, ':'); i >= 0 {
|
||||
host = host[:i]
|
||||
}
|
||||
root := appRootLabel()
|
||||
|
||||
|
||||
parts := strings.Split(host, ".")
|
||||
if len(parts) < 3 {
|
||||
return ""
|
||||
@@ -55,7 +50,7 @@ func hostSlug(host string) string {
|
||||
return parts[0]
|
||||
}
|
||||
|
||||
func OrgFromHost(c *gin.Context) (*models.Org, bool) {
|
||||
func InstanceFromHost(c *gin.Context) (*models.Instance, bool) {
|
||||
slug := hostSlug(c.Request.Host)
|
||||
if slug == "" {
|
||||
return nil, false
|
||||
@@ -67,10 +62,9 @@ func OrgFromHost(c *gin.Context) (*models.Org, bool) {
|
||||
}
|
||||
orgCacheMu.Unlock()
|
||||
|
||||
org, err := services.GetOrgBySlug(slug)
|
||||
org, err := services.GetInstanceBySlug(slug)
|
||||
if err != nil || org == nil {
|
||||
|
||||
|
||||
|
||||
return nil, false
|
||||
}
|
||||
orgCacheMu.Lock()
|
||||
@@ -37,7 +37,7 @@ func HandleLocalLogin(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
sessionID, err := SaveSession(c.Request.Context(), &Session{
|
||||
UserID: u.UserID, OrgID: u.OrgID, Role: u.Role, Email: u.Email,
|
||||
UserID: u.UserID, InstanceID: u.InstanceID, Role: u.Role, Email: u.Email,
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "session save failed"})
|
||||
@@ -50,13 +50,13 @@ func HandleLocalLogin(c *gin.Context) {
|
||||
|
||||
func HandleBootstrapStatus(c *gin.Context) {
|
||||
var (
|
||||
n int64
|
||||
err error
|
||||
orgName string
|
||||
n int64
|
||||
err error
|
||||
instName string
|
||||
)
|
||||
if org, ok := OrgFromHost(c); ok {
|
||||
n, err = services.CountOrgUsers(org.OrgID)
|
||||
orgName = org.Name
|
||||
if inst, ok := InstanceFromHost(c); ok {
|
||||
n, err = services.CountInstanceUsers(inst.InstanceID)
|
||||
instName = inst.Name
|
||||
} else {
|
||||
n, err = services.CountUsers()
|
||||
}
|
||||
@@ -64,12 +64,9 @@ func HandleBootstrapStatus(c *gin.Context) {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"needs_setup": n == 0, "org_name": orgName})
|
||||
c.JSON(http.StatusOK, gin.H{"needs_setup": n == 0, "instance_name": instName})
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
func HandleBootstrap(c *gin.Context) {
|
||||
n, err := services.CountUsers()
|
||||
if err != nil {
|
||||
@@ -81,34 +78,34 @@ func HandleBootstrap(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
OrgName string `json:"org_name"`
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
InstanceName string `json:"instance_name"`
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil || body.OrgName == "" || body.Email == "" || len(body.Password) < 8 {
|
||||
if err := c.ShouldBindJSON(&body); err != nil || body.InstanceName == "" || body.Email == "" || len(body.Password) < 8 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "org_name, email, and password (>=8 chars) required"})
|
||||
return
|
||||
}
|
||||
|
||||
orgCount, err := services.CountOrgs()
|
||||
orgCount, err := services.CountInstances()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
var org *models.Org
|
||||
var inst *models.Instance
|
||||
switch orgCount {
|
||||
case 0:
|
||||
org, err = services.CreateOrg(body.OrgName)
|
||||
inst, err = services.CreateInstance(body.InstanceName)
|
||||
case 1:
|
||||
var existing *models.Org
|
||||
existing, err = services.FirstOrg()
|
||||
var existing *models.Instance
|
||||
existing, err = services.FirstInstance()
|
||||
if err == nil {
|
||||
org, err = services.AdoptOrg(existing.OrgID, body.OrgName)
|
||||
inst, err = services.AdoptInstance(existing.InstanceID, body.InstanceName)
|
||||
}
|
||||
default:
|
||||
c.JSON(http.StatusConflict, gin.H{"error": fmt.Sprintf(
|
||||
"cannot bootstrap: %d organizations already exist but no users do; "+
|
||||
"create the owner against the intended org rather than through setup, "+
|
||||
"create the owner against the intended inst rather than through setup, "+
|
||||
"or remove the unintended orgs and retry", orgCount)})
|
||||
return
|
||||
}
|
||||
@@ -116,20 +113,20 @@ func HandleBootstrap(c *gin.Context) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
u, err := services.CreateUser(org.OrgID, body.Email, body.Password, "owner", "local")
|
||||
u, err := services.CreateUser(inst.InstanceID, body.Email, body.Password, "owner", "local")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
sessionID, err := SaveSession(c.Request.Context(), &Session{
|
||||
UserID: u.UserID, OrgID: u.OrgID, Role: u.Role, Email: u.Email,
|
||||
UserID: u.UserID, InstanceID: u.InstanceID, Role: u.Role, Email: u.Email,
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "session save failed"})
|
||||
return
|
||||
}
|
||||
SetSessionCookie(c, sessionID)
|
||||
c.JSON(http.StatusCreated, gin.H{"org": org, "slug": org.Slug})
|
||||
c.JSON(http.StatusCreated, gin.H{"instance": inst, "slug": inst.Slug})
|
||||
}
|
||||
|
||||
func HandleMe(c *gin.Context) {
|
||||
@@ -143,14 +140,12 @@ func HandleMe(c *gin.Context) {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "session expired"})
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
|
||||
if hostOrg, ok := OrgFromHost(c); ok && hostOrg.OrgID != sess.OrgID {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "org host mismatch"})
|
||||
|
||||
if hostInstance, ok := InstanceFromHost(c); ok && hostInstance.InstanceID != sess.InstanceID {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "instance host mismatch"})
|
||||
return
|
||||
}
|
||||
|
||||
org, _ := services.GetOrg(sess.OrgID)
|
||||
c.JSON(http.StatusOK, gin.H{"user": sess, "org": org})
|
||||
inst, _ := services.GetInstance(sess.InstanceID)
|
||||
c.JSON(http.StatusOK, gin.H{"user": sess, "instance": inst})
|
||||
}
|
||||
|
||||
@@ -14,9 +14,9 @@ func GetSessionFromContext(c *gin.Context) *Session {
|
||||
return sess
|
||||
}
|
||||
|
||||
func OrgID(c *gin.Context) string {
|
||||
func InstanceID(c *gin.Context) string {
|
||||
if s := GetSessionFromContext(c); s != nil {
|
||||
return s.OrgID
|
||||
return s.InstanceID
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -62,15 +62,15 @@ func Middleware() gin.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
if sess.OrgID == "" {
|
||||
if sess.InstanceID == "" {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "session has no organization"})
|
||||
return
|
||||
}
|
||||
|
||||
c.Set(ctxSessionKey, sess)
|
||||
|
||||
if hostOrg, ok := OrgFromHost(c); ok && hostOrg.OrgID != sess.OrgID {
|
||||
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "org host mismatch"})
|
||||
if hostInstance, ok := InstanceFromHost(c); ok && hostInstance.InstanceID != sess.InstanceID {
|
||||
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "instance host mismatch"})
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -18,9 +18,9 @@ var (
|
||||
provCache = map[string]*oidc.Provider{}
|
||||
)
|
||||
|
||||
func EvictOIDCProvider(orgID string) {
|
||||
func EvictOIDCProvider(instanceID string) {
|
||||
provMu.Lock()
|
||||
delete(provCache, orgID)
|
||||
delete(provCache, instanceID)
|
||||
provMu.Unlock()
|
||||
}
|
||||
|
||||
@@ -32,17 +32,17 @@ func redirectURL(c *gin.Context) string {
|
||||
return fmt.Sprintf("%s://%s/auth/oidc/callback", scheme, c.Request.Host)
|
||||
}
|
||||
|
||||
func providerForOrg(ctx context.Context, c *gin.Context, orgID string) (*oidc.Provider, *oauth2.Config, error) {
|
||||
cfg, err := services.GetOrgOIDC(orgID)
|
||||
func providerForOrg(ctx context.Context, c *gin.Context, instanceID string) (*oidc.Provider, *oauth2.Config, error) {
|
||||
cfg, err := services.GetInstanceOIDC(instanceID)
|
||||
if err != nil || !cfg.Enabled {
|
||||
return nil, nil, fmt.Errorf("org SSO not configured")
|
||||
return nil, nil, fmt.Errorf("inst SSO not configured")
|
||||
}
|
||||
secret, err := services.GetOrgOIDCSecret(orgID)
|
||||
secret, err := services.GetInstanceOIDCSecret(instanceID)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
provMu.Lock()
|
||||
p := provCache[orgID]
|
||||
p := provCache[instanceID]
|
||||
provMu.Unlock()
|
||||
if p == nil {
|
||||
p, err = oidc.NewProvider(ctx, cfg.Issuer)
|
||||
@@ -50,7 +50,7 @@ func providerForOrg(ctx context.Context, c *gin.Context, orgID string) (*oidc.Pr
|
||||
return nil, nil, err
|
||||
}
|
||||
provMu.Lock()
|
||||
provCache[orgID] = p
|
||||
provCache[instanceID] = p
|
||||
provMu.Unlock()
|
||||
}
|
||||
return p, &oauth2.Config{
|
||||
@@ -61,13 +61,13 @@ func providerForOrg(ctx context.Context, c *gin.Context, orgID string) (*oidc.Pr
|
||||
}
|
||||
|
||||
func HandleOIDCStart(c *gin.Context) {
|
||||
org, ok := OrgFromHost(c)
|
||||
inst, ok := InstanceFromHost(c)
|
||||
if !ok {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "unknown organization host"})
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
_, oauthCfg, err := providerForOrg(ctx, c, org.OrgID)
|
||||
_, oauthCfg, err := providerForOrg(ctx, c, inst.InstanceID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
@@ -77,7 +77,7 @@ func HandleOIDCStart(c *gin.Context) {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "state gen failed"})
|
||||
return
|
||||
}
|
||||
if err := SaveStateOrg(ctx, state, org.OrgID); err != nil {
|
||||
if err := SaveStateOrg(ctx, state, inst.InstanceID); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "state save failed"})
|
||||
return
|
||||
}
|
||||
@@ -86,12 +86,12 @@ func HandleOIDCStart(c *gin.Context) {
|
||||
|
||||
func HandleOIDCCallback(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
orgID, ok := ConsumeStateOrg(ctx, c.Query("state"))
|
||||
instanceID, ok := ConsumeStateOrg(ctx, c.Query("state"))
|
||||
if !ok {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid state"})
|
||||
return
|
||||
}
|
||||
provider, oauthCfg, err := providerForOrg(ctx, c, orgID)
|
||||
provider, oauthCfg, err := providerForOrg(ctx, c, instanceID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
@@ -123,19 +123,19 @@ func HandleOIDCCallback(c *gin.Context) {
|
||||
email := strings.ToLower(claims.Email)
|
||||
u, err := services.GetUserByEmail(email)
|
||||
if err != nil {
|
||||
|
||||
u, err = services.CreateUser(orgID, email, "", "member", "oidc")
|
||||
|
||||
u, err = services.CreateUser(instanceID, email, "", "member", "oidc")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "provisioning failed"})
|
||||
return
|
||||
}
|
||||
} else if u.OrgID != orgID {
|
||||
} else if u.InstanceID != instanceID {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "email belongs to a different organization"})
|
||||
return
|
||||
}
|
||||
|
||||
sessionID, err := SaveSession(ctx, &Session{
|
||||
UserID: u.UserID, OrgID: u.OrgID, Role: u.Role, Email: u.Email, Name: claims.Name,
|
||||
UserID: u.UserID, InstanceID: u.InstanceID, Role: u.Role, Email: u.Email, Name: claims.Name,
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "session save failed"})
|
||||
|
||||
@@ -16,11 +16,11 @@ const sessionPrefix = "km:session:"
|
||||
const statePrefix = "km:state:"
|
||||
|
||||
type Session struct {
|
||||
UserID string `json:"user_id"`
|
||||
OrgID string `json:"org_id"`
|
||||
Role string `json:"role"`
|
||||
Email string `json:"email"`
|
||||
Name string `json:"name"`
|
||||
UserID string `json:"user_id"`
|
||||
InstanceID string `json:"instance_id"`
|
||||
Role string `json:"role"`
|
||||
Email string `json:"email"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
var rdb *redis.Client
|
||||
@@ -71,14 +71,14 @@ func DeleteSession(ctx context.Context, id string) error {
|
||||
return rdb.Del(ctx, sessionPrefix+id).Err()
|
||||
}
|
||||
|
||||
func SaveStateOrg(ctx context.Context, state, orgID string) error {
|
||||
return rdb.Set(ctx, statePrefix+state, orgID, 10*time.Minute).Err()
|
||||
func SaveStateOrg(ctx context.Context, state, instanceID string) error {
|
||||
return rdb.Set(ctx, statePrefix+state, instanceID, 10*time.Minute).Err()
|
||||
}
|
||||
|
||||
func ConsumeStateOrg(ctx context.Context, state string) (string, bool) {
|
||||
orgID, err := rdb.GetDel(ctx, statePrefix+state).Result()
|
||||
if err != nil || orgID == "" {
|
||||
instanceID, err := rdb.GetDel(ctx, statePrefix+state).Result()
|
||||
if err != nil || instanceID == "" {
|
||||
return "", false
|
||||
}
|
||||
return orgID, true
|
||||
return instanceID, true
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
|
||||
const (
|
||||
TypeHTTP = "http"
|
||||
TypeTCP = "tcp"
|
||||
@@ -20,7 +19,6 @@ const (
|
||||
TypeTLS = "tls"
|
||||
)
|
||||
|
||||
|
||||
type Spec struct {
|
||||
Type string
|
||||
URL string
|
||||
@@ -30,11 +28,10 @@ type Spec struct {
|
||||
ExpectedStatus int
|
||||
Keyword string
|
||||
TLSWarnDays int
|
||||
Insecure bool
|
||||
Insecure bool
|
||||
TimeoutSec int
|
||||
}
|
||||
|
||||
|
||||
type Result struct {
|
||||
Up bool
|
||||
LatencyMs int
|
||||
@@ -50,7 +47,6 @@ func (s Spec) timeout() time.Duration {
|
||||
return time.Duration(t) * time.Second
|
||||
}
|
||||
|
||||
|
||||
func Run(ctx context.Context, s Spec) Result {
|
||||
switch s.Type {
|
||||
case TypeHTTP:
|
||||
@@ -77,7 +73,7 @@ func runHTTP(ctx context.Context, s Spec) Result {
|
||||
}
|
||||
client := &http.Client{Timeout: s.timeout()}
|
||||
if s.Insecure {
|
||||
client.Transport = &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}}
|
||||
client.Transport = &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}}
|
||||
}
|
||||
start := time.Now()
|
||||
req, err := http.NewRequestWithContext(ctx, method, s.URL, nil)
|
||||
@@ -156,9 +152,6 @@ func runTLS(ctx context.Context, s Spec) Result {
|
||||
|
||||
func msSince(t time.Time) int { return int(time.Since(t).Milliseconds()) }
|
||||
|
||||
|
||||
|
||||
|
||||
func runICMP(ctx context.Context, s Spec) Result {
|
||||
dst, err := net.ResolveIPAddr("ip4", s.Host)
|
||||
if err != nil {
|
||||
@@ -188,18 +181,18 @@ func runICMP(ctx context.Context, s Spec) Result {
|
||||
if err != nil {
|
||||
return Result{LatencyMs: msSince(start), Message: "no reply"}
|
||||
}
|
||||
|
||||
|
||||
if n < 28 || peer.String() != dst.String() {
|
||||
continue
|
||||
}
|
||||
if reply[20] == 0 {
|
||||
if reply[20] == 0 {
|
||||
return Result{Up: true, LatencyMs: msSince(start)}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func icmpEcho(id, seq int) []byte {
|
||||
|
||||
|
||||
b := []byte{8, 0, 0, 0, byte(id >> 8), byte(id), byte(seq >> 8), byte(seq)}
|
||||
cs := icmpChecksum(b)
|
||||
b[2] = byte(cs >> 8)
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"encoding/json"
|
||||
)
|
||||
|
||||
|
||||
type JSONCodec struct{}
|
||||
|
||||
func (JSONCodec) Marshal(v interface{}) ([]byte, error) {
|
||||
@@ -16,5 +15,5 @@ func (JSONCodec) Unmarshal(data []byte, v interface{}) error {
|
||||
}
|
||||
|
||||
func (JSONCodec) Name() string {
|
||||
return "proto"
|
||||
return "proto"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
|
||||
|
||||
|
||||
package pb
|
||||
|
||||
import (
|
||||
@@ -11,8 +8,6 @@ import (
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
|
||||
|
||||
type RegisterRequest struct {
|
||||
ServerId string `json:"server_id"`
|
||||
PreRegToken string `json:"pre_reg_token"`
|
||||
@@ -47,8 +42,6 @@ type UploadKeyResponse struct {
|
||||
KeyId string `json:"key_id"`
|
||||
}
|
||||
|
||||
|
||||
|
||||
type PackageUpdate struct {
|
||||
Name string `json:"name"`
|
||||
CurrentVersion string `json:"current_version,omitempty"`
|
||||
@@ -63,8 +56,6 @@ type ReportUpdatesRequest struct {
|
||||
|
||||
type ReportUpdatesResponse struct{}
|
||||
|
||||
|
||||
|
||||
type CPUReport struct {
|
||||
Model string `json:"model,omitempty"`
|
||||
Cores int `json:"cores,omitempty"`
|
||||
@@ -95,8 +86,6 @@ type InventoryReport struct {
|
||||
}
|
||||
type InventoryReportResponse struct{}
|
||||
|
||||
|
||||
|
||||
type MonitorSpec struct {
|
||||
MonitorId string `json:"monitor_id"`
|
||||
Type string `json:"type"`
|
||||
@@ -119,11 +108,11 @@ type SyncMonitorsResponse struct {
|
||||
Monitors []MonitorSpec `json:"monitors,omitempty"`
|
||||
}
|
||||
type CheckResult struct {
|
||||
MonitorId string `json:"monitor_id"`
|
||||
Up bool `json:"up"`
|
||||
LatencyMs int `json:"latency_ms"`
|
||||
Message string `json:"message,omitempty"`
|
||||
CertExpiryUnix int64 `json:"cert_expiry_unix,omitempty"`
|
||||
MonitorId string `json:"monitor_id"`
|
||||
Up bool `json:"up"`
|
||||
LatencyMs int `json:"latency_ms"`
|
||||
Message string `json:"message,omitempty"`
|
||||
CertExpiryUnix int64 `json:"cert_expiry_unix,omitempty"`
|
||||
}
|
||||
type ReportChecksRequest struct {
|
||||
ServerId string `json:"server_id"`
|
||||
@@ -144,8 +133,6 @@ type ServerCommand struct {
|
||||
CleanupWorkspace *CleanupWorkspaceCmd `json:"cleanup_workspace,omitempty"`
|
||||
}
|
||||
|
||||
|
||||
|
||||
type CleanupWorkspaceCmd struct {
|
||||
WorkspaceId string `json:"workspace_id"`
|
||||
}
|
||||
@@ -168,12 +155,12 @@ type GenerateKeyCmd struct {
|
||||
}
|
||||
|
||||
type AgentMessage struct {
|
||||
ServerId string `json:"server_id"`
|
||||
AgentToken string `json:"agent_token"`
|
||||
Ready *AgentReady `json:"ready,omitempty"`
|
||||
Result *CommandResult `json:"result,omitempty"`
|
||||
StepResult *StepResult `json:"step_result,omitempty"`
|
||||
StepOutput *StepOutputChunk `json:"step_output,omitempty"`
|
||||
ServerId string `json:"server_id"`
|
||||
AgentToken string `json:"agent_token"`
|
||||
Ready *AgentReady `json:"ready,omitempty"`
|
||||
Result *CommandResult `json:"result,omitempty"`
|
||||
StepResult *StepResult `json:"step_result,omitempty"`
|
||||
StepOutput *StepOutputChunk `json:"step_output,omitempty"`
|
||||
}
|
||||
|
||||
type AgentReady struct{}
|
||||
@@ -189,8 +176,7 @@ type RunStepCmd struct {
|
||||
Script string `json:"script"`
|
||||
Env map[string]string `json:"env,omitempty"`
|
||||
TimeoutSeconds int `json:"timeout_seconds,omitempty"`
|
||||
|
||||
|
||||
|
||||
WorkspaceId string `json:"workspace_id,omitempty"`
|
||||
}
|
||||
|
||||
@@ -209,8 +195,6 @@ type StepOutputChunk struct {
|
||||
Eof bool `json:"eof,omitempty"`
|
||||
}
|
||||
|
||||
|
||||
|
||||
type Vantage_CommandStreamServer interface {
|
||||
Send(*ServerCommand) error
|
||||
Recv() (*AgentMessage, error)
|
||||
@@ -233,8 +217,6 @@ func (s *keyManagerCommandStreamServer) Recv() (*AgentMessage, error) {
|
||||
return m, nil
|
||||
}
|
||||
|
||||
|
||||
|
||||
type Vantage_CommandStreamClient interface {
|
||||
Send(*AgentMessage) error
|
||||
Recv() (*ServerCommand, error)
|
||||
@@ -257,8 +239,6 @@ func (c *vantageCommandStreamClient) Recv() (*ServerCommand, error) {
|
||||
return m, nil
|
||||
}
|
||||
|
||||
|
||||
|
||||
type VantageServer interface {
|
||||
Register(context.Context, *RegisterRequest) (*RegisterResponse, error)
|
||||
SyncKeys(context.Context, *SyncRequest) (*SyncResponse, error)
|
||||
@@ -304,8 +284,6 @@ func (UnimplementedVantageServer) CommandStream(Vantage_CommandStreamServer) err
|
||||
return status.Errorf(codes.Unimplemented, "method CommandStream not implemented")
|
||||
}
|
||||
|
||||
|
||||
|
||||
type VantageClient interface {
|
||||
Register(ctx context.Context, in *RegisterRequest, opts ...grpc.CallOption) (*RegisterResponse, error)
|
||||
SyncKeys(ctx context.Context, in *SyncRequest, opts ...grpc.CallOption) (*SyncResponse, error)
|
||||
@@ -389,8 +367,6 @@ func (c *keyManagerClient) CommandStream(ctx context.Context, opts ...grpc.CallO
|
||||
return &vantageCommandStreamClient{stream}, nil
|
||||
}
|
||||
|
||||
|
||||
|
||||
func RegisterVantageServer(s grpc.ServiceRegistrar, srv VantageServer) {
|
||||
s.RegisterService(&Vantage_ServiceDesc, srv)
|
||||
}
|
||||
|
||||
@@ -62,14 +62,12 @@ func (s *vantageServer) UploadGeneratedKey(ctx context.Context, req *pb.UploadKe
|
||||
return nil, status.Errorf(codes.Unauthenticated, "invalid agent token")
|
||||
}
|
||||
|
||||
|
||||
key, err := services.CreateKey(srv.OrgID, req.Label, req.PublicKey, "generated", srv.ServerID, req.PrivateKey, "")
|
||||
key, err := services.CreateKey(srv.InstanceID, req.Label, req.PublicKey, "generated", srv.ServerID, req.PrivateKey, "")
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to store key: %v", err)
|
||||
}
|
||||
|
||||
|
||||
if _, err := services.AssignKey(srv.OrgID, key.KeyID, srv.ServerID); err != nil {
|
||||
if _, err := services.AssignKey(srv.InstanceID, key.KeyID, srv.ServerID); err != nil {
|
||||
log.Printf("failed to auto-assign generated key: %v", err)
|
||||
}
|
||||
|
||||
@@ -112,7 +110,7 @@ func (s *vantageServer) SyncMonitors(ctx context.Context, req *pb.SyncMonitorsRe
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Unauthenticated, "invalid agent token")
|
||||
}
|
||||
monitors, err := services.ListMonitorsForRunner(srv.OrgID, srv.ServerID)
|
||||
monitors, err := services.ListMonitorsForRunner(srv.InstanceID, srv.ServerID)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "list monitors")
|
||||
}
|
||||
@@ -147,7 +145,7 @@ func (s *vantageServer) ReportChecks(ctx context.Context, req *pb.ReportChecksRe
|
||||
t := time.Unix(r.CertExpiryUnix, 0)
|
||||
res.CertExpiry = &t
|
||||
}
|
||||
if err := services.IngestResult(srv.OrgID, srv.ServerID, r.MonitorId, res); err != nil {
|
||||
if err := services.IngestResult(srv.InstanceID, srv.ServerID, r.MonitorId, res); err != nil {
|
||||
log.Printf("ingest check %s: %v", r.MonitorId, err)
|
||||
}
|
||||
}
|
||||
@@ -155,7 +153,7 @@ func (s *vantageServer) ReportChecks(ctx context.Context, req *pb.ReportChecksRe
|
||||
}
|
||||
|
||||
func (s *vantageServer) CommandStream(stream pb.Vantage_CommandStreamServer) error {
|
||||
|
||||
|
||||
msg, err := stream.Recv()
|
||||
if err != nil {
|
||||
return status.Errorf(codes.InvalidArgument, "expected initial auth message: %v", err)
|
||||
@@ -176,8 +174,6 @@ func (s *vantageServer) CommandStream(stream pb.Vantage_CommandStreamServer) err
|
||||
log.Printf("agent %s connected command stream", srv.ServerID)
|
||||
defer log.Printf("agent %s disconnected command stream", srv.ServerID)
|
||||
|
||||
|
||||
|
||||
go func() {
|
||||
for {
|
||||
m, err := stream.Recv()
|
||||
@@ -224,15 +220,13 @@ func StartGRPC(port int) error {
|
||||
}
|
||||
|
||||
s := grpc.NewServer(
|
||||
|
||||
|
||||
|
||||
grpc.KeepaliveEnforcementPolicy(keepalive.EnforcementPolicy{
|
||||
MinTime: 20 * time.Second,
|
||||
PermitWithoutStream: false,
|
||||
}),
|
||||
grpc.KeepaliveParams(keepalive.ServerParameters{
|
||||
|
||||
|
||||
|
||||
Time: 45 * time.Second,
|
||||
Timeout: 10 * time.Second,
|
||||
}),
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
|
||||
type Assignment struct {
|
||||
ID bson.ObjectID `bson:"_id,omitempty" json:"_id,omitempty"`
|
||||
OrgID string `bson:"org_id" json:"org_id"`
|
||||
InstanceID string `bson:"instance_id" json:"instance_id"`
|
||||
KeyID string `bson:"key_id" json:"key_id"`
|
||||
ServerID string `bson:"server_id" json:"server_id"`
|
||||
AssignedAt time.Time `bson:"assigned_at" json:"assigned_at"`
|
||||
|
||||
@@ -7,12 +7,12 @@ import (
|
||||
)
|
||||
|
||||
type AuditEvent struct {
|
||||
ID bson.ObjectID `bson:"_id,omitempty" json:"id"`
|
||||
OrgID string `bson:"org_id" json:"org_id"`
|
||||
EventType string `bson:"event_type" json:"event_type"`
|
||||
Actor string `bson:"actor" json:"actor"`
|
||||
ServerID string `bson:"server_id,omitempty" json:"server_id,omitempty"`
|
||||
KeyID string `bson:"key_id,omitempty" json:"key_id,omitempty"`
|
||||
Details string `bson:"details" json:"details"`
|
||||
CreatedAt time.Time `bson:"created_at" json:"created_at"`
|
||||
ID bson.ObjectID `bson:"_id,omitempty" json:"id"`
|
||||
InstanceID string `bson:"instance_id" json:"instance_id"`
|
||||
EventType string `bson:"event_type" json:"event_type"`
|
||||
Actor string `bson:"actor" json:"actor"`
|
||||
ServerID string `bson:"server_id,omitempty" json:"server_id,omitempty"`
|
||||
KeyID string `bson:"key_id,omitempty" json:"key_id,omitempty"`
|
||||
Details string `bson:"details" json:"details"`
|
||||
CreatedAt time.Time `bson:"created_at" json:"created_at"`
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
|
||||
const (
|
||||
ChannelWebhook = "webhook"
|
||||
ChannelSMTP = "smtp"
|
||||
@@ -15,16 +14,13 @@ const (
|
||||
ChannelTelegram = "telegram"
|
||||
)
|
||||
|
||||
|
||||
|
||||
|
||||
type NotificationChannel struct {
|
||||
ID bson.ObjectID `bson:"_id,omitempty" json:"_id,omitempty"`
|
||||
OrgID string `bson:"org_id" json:"org_id"`
|
||||
ChannelID string `bson:"channel_id" json:"channel_id"`
|
||||
Name string `bson:"name" json:"name"`
|
||||
Type string `bson:"type" json:"type"`
|
||||
Config map[string]string `bson:"config" json:"config"`
|
||||
Enabled bool `bson:"enabled" json:"enabled"`
|
||||
CreatedAt time.Time `bson:"created_at" json:"created_at"`
|
||||
ID bson.ObjectID `bson:"_id,omitempty" json:"_id,omitempty"`
|
||||
InstanceID string `bson:"instance_id" json:"instance_id"`
|
||||
ChannelID string `bson:"channel_id" json:"channel_id"`
|
||||
Name string `bson:"name" json:"name"`
|
||||
Type string `bson:"type" json:"type"`
|
||||
Config map[string]string `bson:"config" json:"config"`
|
||||
Enabled bool `bson:"enabled" json:"enabled"`
|
||||
CreatedAt time.Time `bson:"created_at" json:"created_at"`
|
||||
}
|
||||
|
||||
@@ -7,19 +7,17 @@ import (
|
||||
)
|
||||
|
||||
type ConsoleSession struct {
|
||||
ID bson.ObjectID `bson:"_id,omitempty" json:"_id,omitempty"`
|
||||
OrgID string `bson:"org_id" json:"org_id"`
|
||||
SessionID string `bson:"session_id" json:"session_id"`
|
||||
ServerID string `bson:"server_id" json:"server_id"`
|
||||
Protocol string `bson:"protocol" json:"protocol"`
|
||||
KeyID string `bson:"key_id,omitempty" json:"key_id,omitempty"`
|
||||
User string `bson:"user" json:"user"`
|
||||
StartedAt time.Time `bson:"started_at" json:"started_at"`
|
||||
EndedAt *time.Time `bson:"ended_at,omitempty" json:"ended_at,omitempty"`
|
||||
ClientIP string `bson:"client_ip,omitempty" json:"client_ip,omitempty"`
|
||||
ID bson.ObjectID `bson:"_id,omitempty" json:"_id,omitempty"`
|
||||
InstanceID string `bson:"instance_id" json:"instance_id"`
|
||||
SessionID string `bson:"session_id" json:"session_id"`
|
||||
ServerID string `bson:"server_id" json:"server_id"`
|
||||
Protocol string `bson:"protocol" json:"protocol"`
|
||||
KeyID string `bson:"key_id,omitempty" json:"key_id,omitempty"`
|
||||
User string `bson:"user" json:"user"`
|
||||
StartedAt time.Time `bson:"started_at" json:"started_at"`
|
||||
EndedAt *time.Time `bson:"ended_at,omitempty" json:"ended_at,omitempty"`
|
||||
ClientIP string `bson:"client_ip,omitempty" json:"client_ip,omitempty"`
|
||||
|
||||
|
||||
|
||||
TokenConsumedAt *time.Time `bson:"token_consumed_at,omitempty" json:"-"`
|
||||
|
||||
SSHUsername string `bson:"ssh_username,omitempty" json:"ssh_username,omitempty"`
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
package models
|
||||
|
||||
import shared "github.com/mrhid6/vantage/shared/models"
|
||||
|
||||
// Instance is defined in the shared module because sitesvc and the admin
|
||||
// control plane write the same documents.
|
||||
type Instance = shared.Instance
|
||||
@@ -8,12 +8,12 @@ import (
|
||||
|
||||
type Key struct {
|
||||
ID bson.ObjectID `bson:"_id,omitempty" json:"_id,omitempty"`
|
||||
OrgID string `bson:"org_id" json:"org_id"`
|
||||
InstanceID string `bson:"instance_id" json:"instance_id"`
|
||||
KeyID string `bson:"key_id" json:"key_id"`
|
||||
Label string `bson:"label" json:"label"`
|
||||
PublicKey string `bson:"public_key" json:"public_key"`
|
||||
Fingerprint string `bson:"fingerprint" json:"fingerprint"`
|
||||
Source string `bson:"source" json:"source"`
|
||||
Source string `bson:"source" json:"source"`
|
||||
GeneratedByServerID string `bson:"generated_by_server_id,omitempty" json:"generated_by_server_id,omitempty"`
|
||||
PrivateKeyEncrypted string `bson:"private_key_enc,omitempty" json:"-"`
|
||||
HasPrivateKey bool `bson:"-" json:"has_private_key"`
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
|
||||
const (
|
||||
MonitorHTTP = "http"
|
||||
MonitorTCP = "tcp"
|
||||
@@ -14,15 +13,12 @@ const (
|
||||
MonitorTLS = "tls"
|
||||
)
|
||||
|
||||
|
||||
const (
|
||||
StatusUp = "up"
|
||||
StatusDown = "down"
|
||||
StatusPending = "pending"
|
||||
)
|
||||
|
||||
|
||||
|
||||
const RunnerServer = "server"
|
||||
|
||||
type MonitorTarget struct {
|
||||
@@ -33,29 +29,29 @@ type MonitorTarget struct {
|
||||
ExpectedStatus int `bson:"expected_status,omitempty" json:"expected_status,omitempty"`
|
||||
Keyword string `bson:"keyword,omitempty" json:"keyword,omitempty"`
|
||||
TLSWarnDays int `bson:"tls_warn_days,omitempty" json:"tls_warn_days,omitempty"`
|
||||
Insecure bool `bson:"insecure,omitempty" json:"insecure,omitempty"`
|
||||
Insecure bool `bson:"insecure,omitempty" json:"insecure,omitempty"`
|
||||
}
|
||||
|
||||
type MonitorState struct {
|
||||
Status string `bson:"status" json:"status"`
|
||||
Status string `bson:"status" json:"status"`
|
||||
LastCheckAt *time.Time `bson:"last_check_at,omitempty" json:"last_check_at,omitempty"`
|
||||
LatencyMs int `bson:"latency_ms" json:"latency_ms"`
|
||||
Message string `bson:"message,omitempty" json:"message,omitempty"`
|
||||
CertExpiryAt *time.Time `bson:"cert_expiry_at,omitempty" json:"cert_expiry_at,omitempty"`
|
||||
Fails int `bson:"fails" json:"fails"`
|
||||
Fails int `bson:"fails" json:"fails"`
|
||||
LastNotifiedAt *time.Time `bson:"last_notified_at,omitempty" json:"last_notified_at,omitempty"`
|
||||
}
|
||||
|
||||
type Monitor struct {
|
||||
ID bson.ObjectID `bson:"_id,omitempty" json:"_id,omitempty"`
|
||||
OrgID string `bson:"org_id" json:"org_id"`
|
||||
InstanceID string `bson:"instance_id" json:"instance_id"`
|
||||
MonitorID string `bson:"monitor_id" json:"monitor_id"`
|
||||
Name string `bson:"name" json:"name"`
|
||||
Type string `bson:"type" json:"type"`
|
||||
Type string `bson:"type" json:"type"`
|
||||
Target MonitorTarget `bson:"target" json:"target"`
|
||||
IntervalSec int `bson:"interval_sec" json:"interval_sec"`
|
||||
Runner string `bson:"runner" json:"runner"`
|
||||
Retries int `bson:"retries" json:"retries"`
|
||||
Runner string `bson:"runner" json:"runner"`
|
||||
Retries int `bson:"retries" json:"retries"`
|
||||
Enabled bool `bson:"enabled" json:"enabled"`
|
||||
ChannelIDs []string `bson:"channel_ids,omitempty" json:"channel_ids,omitempty"`
|
||||
State MonitorState `bson:"state" json:"state"`
|
||||
@@ -63,7 +59,7 @@ type Monitor struct {
|
||||
}
|
||||
|
||||
type Incident struct {
|
||||
OrgID string `bson:"org_id" json:"org_id"`
|
||||
InstanceID string `bson:"instance_id" json:"instance_id"`
|
||||
IncidentID string `bson:"incident_id" json:"incident_id"`
|
||||
MonitorID string `bson:"monitor_id" json:"monitor_id"`
|
||||
StartedAt time.Time `bson:"started_at" json:"started_at"`
|
||||
@@ -72,9 +68,9 @@ type Incident struct {
|
||||
}
|
||||
|
||||
type Rollup struct {
|
||||
OrgID string `bson:"org_id" json:"org_id"`
|
||||
InstanceID string `bson:"instance_id" json:"instance_id"`
|
||||
MonitorID string `bson:"monitor_id" json:"monitor_id"`
|
||||
PeriodStart time.Time `bson:"period_start" json:"period_start"`
|
||||
PeriodStart time.Time `bson:"period_start" json:"period_start"`
|
||||
Checks int `bson:"checks" json:"checks"`
|
||||
UpCount int `bson:"up_count" json:"up_count"`
|
||||
SumLatency int64 `bson:"sum_latency" json:"sum_latency"`
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
type Org struct {
|
||||
ID bson.ObjectID `bson:"_id,omitempty" json:"_id,omitempty"`
|
||||
OrgID string `bson:"org_id" json:"org_id"`
|
||||
Name string `bson:"name" json:"name"`
|
||||
Slug string `bson:"slug" json:"slug"`
|
||||
CreatedAt time.Time `bson:"created_at" json:"created_at"`
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
|
||||
type OrgOIDC struct {
|
||||
ID bson.ObjectID `bson:"_id,omitempty" json:"_id,omitempty"`
|
||||
OrgID string `bson:"org_id" json:"org_id"`
|
||||
InstanceID string `bson:"instance_id" json:"instance_id"`
|
||||
Issuer string `bson:"issuer" json:"issuer"`
|
||||
ClientID string `bson:"client_id" json:"client_id"`
|
||||
ClientSecretEnc string `bson:"client_secret_enc,omitempty" json:"-"`
|
||||
|
||||
@@ -6,18 +6,15 @@ import (
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
|
||||
|
||||
type Secret struct {
|
||||
ID bson.ObjectID `bson:"_id,omitempty" json:"-"`
|
||||
OrgID string `bson:"org_id" json:"org_id"`
|
||||
InstanceID string `bson:"instance_id" json:"instance_id"`
|
||||
Group string `bson:"group" json:"group"`
|
||||
Key string `bson:"key" json:"key"`
|
||||
EncryptedValue string `bson:"encrypted_value" json:"-"`
|
||||
UpdatedAt time.Time `bson:"updated_at" json:"updated_at"`
|
||||
}
|
||||
|
||||
|
||||
type GroupSummary struct {
|
||||
Group string `json:"group"`
|
||||
KeyCount int `json:"key_count"`
|
||||
|
||||
@@ -45,7 +45,7 @@ type Inventory struct {
|
||||
|
||||
type Server struct {
|
||||
ID bson.ObjectID `bson:"_id,omitempty" json:"_id,omitempty"`
|
||||
OrgID string `bson:"org_id" json:"org_id"`
|
||||
InstanceID string `bson:"instance_id" json:"instance_id"`
|
||||
ServerID string `bson:"server_id" json:"server_id"`
|
||||
Hostname string `bson:"hostname" json:"hostname"`
|
||||
IPAddress string `bson:"ip_address" json:"ip_address"`
|
||||
|
||||
@@ -1,42 +1,10 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
import shared "github.com/mrhid6/vantage/shared/models"
|
||||
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
type (
|
||||
Settings = shared.Settings
|
||||
AlertSettings = shared.AlertSettings
|
||||
EmailSettings = shared.EmailSettings
|
||||
SecretsSettings = shared.SecretsSettings
|
||||
)
|
||||
|
||||
type AlertSettings struct {
|
||||
Enabled bool `bson:"enabled" json:"enabled"`
|
||||
WebhookURL string `bson:"webhook_url" json:"webhook_url"`
|
||||
OfflineThresholdMinutes int `bson:"offline_threshold_minutes" json:"offline_threshold_minutes"`
|
||||
}
|
||||
|
||||
type EmailSettings struct {
|
||||
Enabled bool `bson:"enabled" json:"enabled"`
|
||||
SMTPHost string `bson:"smtp_host" json:"smtp_host"`
|
||||
SMTPPort int `bson:"smtp_port" json:"smtp_port"`
|
||||
Username string `bson:"username" json:"username"`
|
||||
Password string `bson:"password" json:"password"`
|
||||
FromAddr string `bson:"from_addr" json:"from_addr"`
|
||||
ToAddrs []string `bson:"to_addrs" json:"to_addrs"`
|
||||
UseTLS bool `bson:"use_tls" json:"use_tls"`
|
||||
}
|
||||
|
||||
|
||||
|
||||
type SecretsSettings struct {
|
||||
ReadTokenHash string `bson:"read_token_hash,omitempty" json:"-"`
|
||||
ReadTokenSet bool `bson:"-" json:"read_token_set"`
|
||||
RotatedAt time.Time `bson:"rotated_at,omitempty" json:"rotated_at,omitempty"`
|
||||
}
|
||||
|
||||
type Settings struct {
|
||||
ID bson.ObjectID `bson:"_id,omitempty" json:"-"`
|
||||
OrgID string `bson:"org_id" json:"org_id"`
|
||||
Alerts AlertSettings `bson:"alerts" json:"alerts"`
|
||||
Email EmailSettings `bson:"email" json:"email"`
|
||||
Secrets SecretsSettings `bson:"secrets" json:"secrets"`
|
||||
|
||||
WorkflowLogRetentionDays *int `bson:"workflow_log_retention_days,omitempty" json:"workflow_log_retention_days,omitempty"`
|
||||
}
|
||||
|
||||
@@ -1,35 +1,13 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
import shared "github.com/mrhid6/vantage/shared/models"
|
||||
|
||||
type User = shared.User
|
||||
|
||||
const (
|
||||
RoleOwner = "owner"
|
||||
RoleAdmin = "admin"
|
||||
RoleMember = "member"
|
||||
RoleOwner = shared.RoleOwner
|
||||
RoleAdmin = shared.RoleAdmin
|
||||
RoleMember = shared.RoleMember
|
||||
)
|
||||
|
||||
func ValidRole(role string) bool {
|
||||
switch role {
|
||||
case RoleOwner, RoleAdmin, RoleMember:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type User struct {
|
||||
ID bson.ObjectID `bson:"_id,omitempty" json:"_id,omitempty"`
|
||||
UserID string `bson:"user_id" json:"user_id"`
|
||||
OrgID string `bson:"org_id" json:"org_id"`
|
||||
Email string `bson:"email" json:"email"`
|
||||
PasswordHash string `bson:"password_hash,omitempty" json:"-"`
|
||||
Role string `bson:"role" json:"role"`
|
||||
AuthSource string `bson:"auth_source" json:"auth_source"`
|
||||
CreatedAt time.Time `bson:"created_at" json:"created_at"`
|
||||
LastLogin *time.Time `bson:"last_login,omitempty" json:"last_login,omitempty"`
|
||||
}
|
||||
func ValidRole(role string) bool { return shared.ValidRole(role) }
|
||||
|
||||
@@ -14,16 +14,16 @@ type InputParam struct {
|
||||
|
||||
type WorkflowStep struct {
|
||||
ID bson.ObjectID `bson:"_id,omitempty" json:"-"`
|
||||
OrgID string `bson:"org_id" json:"org_id"`
|
||||
InstanceID string `bson:"instance_id" json:"instance_id"`
|
||||
StepID string `bson:"step_id" json:"step_id"`
|
||||
Name string `bson:"name" json:"name"`
|
||||
Description string `bson:"description" json:"description"`
|
||||
Interpreter string `bson:"interpreter" json:"interpreter"`
|
||||
Interpreter string `bson:"interpreter" json:"interpreter"`
|
||||
Script string `bson:"script" json:"script"`
|
||||
DeclaredOutputs []string `bson:"declared_outputs" json:"declared_outputs"`
|
||||
DeclaredInputs []InputParam `bson:"declared_inputs" json:"declared_inputs"`
|
||||
SecretRefs []string `bson:"secret_refs" json:"secret_refs"`
|
||||
Source string `bson:"source" json:"source"`
|
||||
Source string `bson:"source" json:"source"`
|
||||
Slug string `bson:"slug,omitempty" json:"slug,omitempty"`
|
||||
CreatedAt time.Time `bson:"created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `bson:"updated_at" json:"updated_at"`
|
||||
@@ -33,7 +33,7 @@ type WorkflowStepRef struct {
|
||||
StepID string `bson:"step_id,omitempty" json:"step_id,omitempty"`
|
||||
Inline *WorkflowStep `bson:"inline,omitempty" json:"inline,omitempty"`
|
||||
Order int `bson:"order" json:"order"`
|
||||
OnFailure string `bson:"on_failure" json:"on_failure"`
|
||||
OnFailure string `bson:"on_failure" json:"on_failure"`
|
||||
MaxRetries int `bson:"max_retries" json:"max_retries"`
|
||||
Overrides *StepOverride `bson:"overrides,omitempty" json:"overrides,omitempty"`
|
||||
Inputs map[string]string `bson:"inputs,omitempty" json:"inputs,omitempty"`
|
||||
@@ -46,7 +46,7 @@ type StepOverride struct {
|
||||
|
||||
type Workflow struct {
|
||||
ID bson.ObjectID `bson:"_id,omitempty" json:"-"`
|
||||
OrgID string `bson:"org_id" json:"org_id"`
|
||||
InstanceID string `bson:"instance_id" json:"instance_id"`
|
||||
WorkflowID string `bson:"workflow_id" json:"workflow_id"`
|
||||
Name string `bson:"name" json:"name"`
|
||||
TargetServerIDs []string `bson:"target_server_ids" json:"target_server_ids"`
|
||||
@@ -55,7 +55,6 @@ type Workflow struct {
|
||||
UpdatedAt time.Time `bson:"updated_at" json:"updated_at"`
|
||||
}
|
||||
|
||||
|
||||
type ResolvedStep struct {
|
||||
Order int `bson:"order" json:"order"`
|
||||
Name string `bson:"name" json:"name"`
|
||||
@@ -70,7 +69,7 @@ type ResolvedStep struct {
|
||||
type StepRun struct {
|
||||
Order int `bson:"order" json:"order"`
|
||||
Name string `bson:"name" json:"name"`
|
||||
Status string `bson:"status" json:"status"`
|
||||
Status string `bson:"status" json:"status"`
|
||||
Attempts int `bson:"attempts" json:"attempts"`
|
||||
ExitCode int `bson:"exit_code" json:"exit_code"`
|
||||
LogOffset int64 `bson:"log_offset" json:"log_offset"`
|
||||
@@ -82,7 +81,7 @@ type StepRun struct {
|
||||
type ServerRun struct {
|
||||
ServerID string `bson:"server_id" json:"server_id"`
|
||||
Hostname string `bson:"hostname" json:"hostname"`
|
||||
Status string `bson:"status" json:"status"`
|
||||
Status string `bson:"status" json:"status"`
|
||||
StartedAt *time.Time `bson:"started_at,omitempty" json:"started_at,omitempty"`
|
||||
FinishedAt *time.Time `bson:"finished_at,omitempty" json:"finished_at,omitempty"`
|
||||
RunEnv map[string]string `bson:"run_env" json:"run_env"`
|
||||
@@ -91,12 +90,12 @@ type ServerRun struct {
|
||||
|
||||
type WorkflowRun struct {
|
||||
ID bson.ObjectID `bson:"_id,omitempty" json:"-"`
|
||||
OrgID string `bson:"org_id" json:"org_id"`
|
||||
InstanceID string `bson:"instance_id" json:"instance_id"`
|
||||
RunID string `bson:"run_id" json:"run_id"`
|
||||
WorkflowID string `bson:"workflow_id" json:"workflow_id"`
|
||||
Name string `bson:"name" json:"name"`
|
||||
Steps []ResolvedStep `bson:"steps_snapshot" json:"steps_snapshot"`
|
||||
Status string `bson:"status" json:"status"`
|
||||
Status string `bson:"status" json:"status"`
|
||||
TriggeredBy string `bson:"triggered_by" json:"triggered_by"`
|
||||
StartedAt time.Time `bson:"started_at" json:"started_at"`
|
||||
FinishedAt *time.Time `bson:"finished_at,omitempty" json:"finished_at,omitempty"`
|
||||
|
||||
@@ -40,7 +40,7 @@ func loop(ctx context.Context) {
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
|
||||
for id, r := range active {
|
||||
m, ok := want[id]
|
||||
if !ok || m.IntervalSec != r.intervalSec {
|
||||
@@ -48,7 +48,7 @@ func loop(ctx context.Context) {
|
||||
delete(active, id)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
for id, m := range want {
|
||||
if _, ok := active[id]; ok {
|
||||
continue
|
||||
@@ -86,7 +86,7 @@ func runMonitor(ctx context.Context, m models.Monitor) {
|
||||
}
|
||||
}
|
||||
|
||||
run()
|
||||
run()
|
||||
t := time.NewTicker(interval)
|
||||
defer t.Stop()
|
||||
for {
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
|
||||
|
||||
|
||||
package notify
|
||||
|
||||
import (
|
||||
@@ -10,7 +7,6 @@ import (
|
||||
"github.com/mrhid6/vantage/server/internal/models"
|
||||
)
|
||||
|
||||
|
||||
type Event struct {
|
||||
MonitorName string
|
||||
Type string
|
||||
@@ -20,7 +16,6 @@ type Event struct {
|
||||
Time time.Time
|
||||
}
|
||||
|
||||
|
||||
func (e Event) title() string {
|
||||
verb := "recovered"
|
||||
if e.NewStatus == models.StatusDown {
|
||||
@@ -33,7 +28,6 @@ func (e Event) title() string {
|
||||
return s
|
||||
}
|
||||
|
||||
|
||||
func Dispatch(ch models.NotificationChannel, ev Event) error {
|
||||
switch ch.Type {
|
||||
case models.ChannelWebhook:
|
||||
@@ -51,7 +45,6 @@ func Dispatch(ch models.NotificationChannel, ev Event) error {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func Test(ch models.NotificationChannel) error {
|
||||
return Dispatch(ch, Event{
|
||||
MonitorName: "Test monitor",
|
||||
|
||||
@@ -28,7 +28,6 @@ func postJSON(target string, payload any) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
func dispatchWebhook(ch models.NotificationChannel, ev Event) error {
|
||||
target := ch.Config["url"]
|
||||
if target == "" {
|
||||
|
||||
@@ -13,13 +13,6 @@ import (
|
||||
|
||||
const smtpTimeout = 15 * time.Second
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
func dispatchSMTP(ch models.NotificationChannel, ev Event) error {
|
||||
host := ch.Config["host"]
|
||||
port := ch.Config["port"]
|
||||
@@ -36,7 +29,6 @@ func dispatchSMTP(ch models.NotificationChannel, ev Event) error {
|
||||
}
|
||||
_ = conn.SetDeadline(time.Now().Add(smtpTimeout))
|
||||
|
||||
|
||||
if port == "465" {
|
||||
conn = tls.Client(conn, &tls.Config{ServerName: host})
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ import (
|
||||
"github.com/mrhid6/vantage/server/internal/models"
|
||||
)
|
||||
|
||||
|
||||
const (
|
||||
colBg = "#0f1117"
|
||||
colSurface = "#1a1d27"
|
||||
@@ -23,7 +22,6 @@ const (
|
||||
colDanger = "#ef4444"
|
||||
)
|
||||
|
||||
|
||||
func statusColor(status string) string {
|
||||
switch status {
|
||||
case models.StatusUp:
|
||||
@@ -35,8 +33,6 @@ func statusColor(status string) string {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
func buildMIME(from, to, subject, text, htmlBody string) ([]byte, error) {
|
||||
var buf strings.Builder
|
||||
w := multipart.NewWriter(&buf)
|
||||
@@ -150,7 +146,6 @@ func htmlEmail(ev Event) string {
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
func textEmail(ev Event) string {
|
||||
return strings.Join([]string{
|
||||
ev.title(),
|
||||
|
||||
@@ -11,25 +11,25 @@ import (
|
||||
"go.mongodb.org/mongo-driver/v2/mongo/options"
|
||||
)
|
||||
|
||||
func LogEvent(orgID, eventType, actor, serverID, keyID, details string) {
|
||||
func LogEvent(instanceID, eventType, actor, serverID, keyID, details string) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
event := models.AuditEvent{
|
||||
OrgID: orgID,
|
||||
EventType: eventType,
|
||||
Actor: actor,
|
||||
ServerID: serverID,
|
||||
KeyID: keyID,
|
||||
Details: details,
|
||||
CreatedAt: time.Now(),
|
||||
InstanceID: instanceID,
|
||||
EventType: eventType,
|
||||
Actor: actor,
|
||||
ServerID: serverID,
|
||||
KeyID: keyID,
|
||||
Details: details,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
if _, err := db.Col("audit_logs").InsertOne(ctx, event); err != nil {
|
||||
log.Printf("audit log error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func ListAuditEvents(orgID string, limit int64) ([]models.AuditEvent, error) {
|
||||
func ListAuditEvents(instanceID string, limit int64) ([]models.AuditEvent, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
@@ -37,7 +37,7 @@ func ListAuditEvents(orgID string, limit int64) ([]models.AuditEvent, error) {
|
||||
SetSort(bson.D{{Key: "created_at", Value: -1}}).
|
||||
SetLimit(limit)
|
||||
|
||||
cursor, err := db.Col("audit_logs").Find(ctx, bson.M{"org_id": orgID}, opts)
|
||||
cursor, err := db.Col("audit_logs").Find(ctx, bson.M{"instance_id": instanceID}, opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -13,10 +13,10 @@ import (
|
||||
"go.mongodb.org/mongo-driver/v2/mongo/options"
|
||||
)
|
||||
|
||||
func ListChannels(orgID string) ([]models.NotificationChannel, error) {
|
||||
func ListChannels(instanceID string) ([]models.NotificationChannel, error) {
|
||||
ctx, cancel := monCtx()
|
||||
defer cancel()
|
||||
cur, err := db.Col("notification_channels").Find(ctx, bson.M{"org_id": orgID}, options.Find().SetSort(bson.M{"created_at": 1}))
|
||||
cur, err := db.Col("notification_channels").Find(ctx, bson.M{"instance_id": instanceID}, options.Find().SetSort(bson.M{"created_at": 1}))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -27,11 +27,11 @@ func ListChannels(orgID string) ([]models.NotificationChannel, error) {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func GetChannel(orgID, channelID string) (*models.NotificationChannel, error) {
|
||||
func GetChannel(instanceID, channelID string) (*models.NotificationChannel, error) {
|
||||
ctx, cancel := monCtx()
|
||||
defer cancel()
|
||||
var ch models.NotificationChannel
|
||||
err := db.Col("notification_channels").FindOne(ctx, bson.M{"channel_id": channelID, "org_id": orgID}).Decode(&ch)
|
||||
err := db.Col("notification_channels").FindOne(ctx, bson.M{"channel_id": channelID, "instance_id": instanceID}).Decode(&ch)
|
||||
if errors.Is(err, mongo.ErrNoDocuments) {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -41,14 +41,13 @@ func GetChannel(orgID, channelID string) (*models.NotificationChannel, error) {
|
||||
return &ch, nil
|
||||
}
|
||||
|
||||
|
||||
func GetChannels(orgID string, channelIDs []string) ([]models.NotificationChannel, error) {
|
||||
func GetChannels(instanceID string, channelIDs []string) ([]models.NotificationChannel, error) {
|
||||
if len(channelIDs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
ctx, cancel := monCtx()
|
||||
defer cancel()
|
||||
cur, err := db.Col("notification_channels").Find(ctx, bson.M{"org_id": orgID, "channel_id": bson.M{"$in": channelIDs}})
|
||||
cur, err := db.Col("notification_channels").Find(ctx, bson.M{"instance_id": instanceID, "channel_id": bson.M{"$in": channelIDs}})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -59,11 +58,9 @@ func GetChannels(orgID string, channelIDs []string) ([]models.NotificationChanne
|
||||
return out, nil
|
||||
}
|
||||
|
||||
|
||||
|
||||
func validateChannelIDs(orgID string, channelIDs []string) error {
|
||||
func validateChannelIDs(instanceID string, channelIDs []string) error {
|
||||
for _, id := range channelIDs {
|
||||
ch, err := GetChannel(orgID, id)
|
||||
ch, err := GetChannel(instanceID, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -74,10 +71,10 @@ func validateChannelIDs(orgID string, channelIDs []string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func CreateChannel(orgID string, ch *models.NotificationChannel) (*models.NotificationChannel, error) {
|
||||
func CreateChannel(instanceID string, ch *models.NotificationChannel) (*models.NotificationChannel, error) {
|
||||
ctx, cancel := monCtx()
|
||||
defer cancel()
|
||||
ch.OrgID = orgID
|
||||
ch.InstanceID = instanceID
|
||||
ch.ChannelID = uuid.NewString()
|
||||
ch.CreatedAt = time.Now()
|
||||
if ch.Config == nil {
|
||||
@@ -89,23 +86,22 @@ func CreateChannel(orgID string, ch *models.NotificationChannel) (*models.Notifi
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
func UpdateChannel(orgID, channelID string, upd bson.M) error {
|
||||
func UpdateChannel(instanceID, channelID string, upd bson.M) error {
|
||||
ctx, cancel := monCtx()
|
||||
defer cancel()
|
||||
_, err := db.Col("notification_channels").UpdateOne(ctx, bson.M{"channel_id": channelID, "org_id": orgID}, bson.M{"$set": upd})
|
||||
_, err := db.Col("notification_channels").UpdateOne(ctx, bson.M{"channel_id": channelID, "instance_id": instanceID}, bson.M{"$set": upd})
|
||||
return err
|
||||
}
|
||||
|
||||
func DeleteChannel(orgID, channelID string) error {
|
||||
func DeleteChannel(instanceID, channelID string) error {
|
||||
ctx, cancel := monCtx()
|
||||
defer cancel()
|
||||
_, err := db.Col("notification_channels").DeleteOne(ctx, bson.M{"channel_id": channelID, "org_id": orgID})
|
||||
_, err := db.Col("notification_channels").DeleteOne(ctx, bson.M{"channel_id": channelID, "instance_id": instanceID})
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
func TestChannel(orgID, channelID string) error {
|
||||
ch, err := GetChannel(orgID, channelID)
|
||||
func TestChannel(instanceID, channelID string) error {
|
||||
ch, err := GetChannel(instanceID, channelID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ import (
|
||||
)
|
||||
|
||||
func sessionHMACKey() ([]byte, error) {
|
||||
|
||||
|
||||
k, err := encryptionKey()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -29,7 +29,6 @@ func sessionHMACKey() ([]byte, error) {
|
||||
|
||||
func b64(b []byte) string { return base64.RawURLEncoding.EncodeToString(b) }
|
||||
|
||||
|
||||
func SignSessionToken(sessionID string, ttl time.Duration) (string, error) {
|
||||
key, err := sessionHMACKey()
|
||||
if err != nil {
|
||||
@@ -42,7 +41,6 @@ func SignSessionToken(sessionID string, ttl time.Duration) (string, error) {
|
||||
return payload + "." + b64(mac.Sum(nil)), nil
|
||||
}
|
||||
|
||||
|
||||
func VerifySessionToken(token string) (string, error) {
|
||||
parts := strings.Split(token, ".")
|
||||
if len(parts) != 3 {
|
||||
@@ -86,10 +84,6 @@ func portOr(v, def int) string {
|
||||
return strconv.Itoa(v)
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
func BuildGuacParams(srv *models.Server, protocol, sshUser, privateKey, passphrase, rdpUser, rdpPass string) (*GuacParams, error) {
|
||||
host := srv.IPAddress
|
||||
switch protocol {
|
||||
@@ -129,19 +123,19 @@ func BuildGuacParams(srv *models.Server, protocol, sshUser, privateKey, passphra
|
||||
}
|
||||
}
|
||||
|
||||
func CreateConsoleSession(orgID, serverID, protocol, keyID, user, clientIP string) (*models.ConsoleSession, error) {
|
||||
func CreateConsoleSession(instanceID, serverID, protocol, keyID, user, clientIP string) (*models.ConsoleSession, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
s := &models.ConsoleSession{
|
||||
OrgID: orgID,
|
||||
SessionID: uuid.NewString(),
|
||||
ServerID: serverID,
|
||||
Protocol: protocol,
|
||||
KeyID: keyID,
|
||||
User: user,
|
||||
ClientIP: clientIP,
|
||||
StartedAt: time.Now(),
|
||||
InstanceID: instanceID,
|
||||
SessionID: uuid.NewString(),
|
||||
ServerID: serverID,
|
||||
Protocol: protocol,
|
||||
KeyID: keyID,
|
||||
User: user,
|
||||
ClientIP: clientIP,
|
||||
StartedAt: time.Now(),
|
||||
}
|
||||
if _, err := db.Col("console_sessions").InsertOne(ctx, s); err != nil {
|
||||
return nil, err
|
||||
@@ -149,19 +143,17 @@ func CreateConsoleSession(orgID, serverID, protocol, keyID, user, clientIP strin
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func GetConsoleSession(orgID, sessionID string) (*models.ConsoleSession, error) {
|
||||
func GetConsoleSession(instanceID, sessionID string) (*models.ConsoleSession, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
var s models.ConsoleSession
|
||||
if err := db.Col("console_sessions").FindOne(ctx, bson.M{"session_id": sessionID, "org_id": orgID}).Decode(&s); err != nil {
|
||||
if err := db.Col("console_sessions").FindOne(ctx, bson.M{"session_id": sessionID, "instance_id": instanceID}).Decode(&s); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &s, nil
|
||||
}
|
||||
|
||||
|
||||
|
||||
func StashConsoleRDPCreds(orgID, sessionID, username, password string) error {
|
||||
func StashConsoleRDPCreds(instanceID, sessionID, username, password string) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
u, err := encryptString(username)
|
||||
@@ -173,17 +165,14 @@ func StashConsoleRDPCreds(orgID, sessionID, username, password string) error {
|
||||
return err
|
||||
}
|
||||
_, err = db.Col("console_sessions").UpdateOne(ctx,
|
||||
bson.M{"session_id": sessionID, "org_id": orgID},
|
||||
bson.M{"session_id": sessionID, "instance_id": instanceID},
|
||||
bson.M{"$set": bson.M{"rdp_user_enc": u, "rdp_pass_enc": p}},
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
func ConsumeConsoleRDPCreds(orgID, sessionID string) (username, password string, err error) {
|
||||
s, err := GetConsoleSession(orgID, sessionID)
|
||||
func ConsumeConsoleRDPCreds(instanceID, sessionID string) (username, password string, err error) {
|
||||
s, err := GetConsoleSession(instanceID, sessionID)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
@@ -203,31 +192,27 @@ func ConsumeConsoleRDPCreds(orgID, sessionID string) (username, password string,
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
_, _ = db.Col("console_sessions").UpdateOne(ctx,
|
||||
bson.M{"session_id": sessionID, "org_id": orgID},
|
||||
bson.M{"session_id": sessionID, "instance_id": instanceID},
|
||||
bson.M{"$unset": bson.M{"rdp_user_enc": "", "rdp_pass_enc": ""}},
|
||||
)
|
||||
return username, password, nil
|
||||
}
|
||||
|
||||
|
||||
func SetConsoleSSHUser(orgID, sessionID, username string) error {
|
||||
func SetConsoleSSHUser(instanceID, sessionID, username string) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
_, err := db.Col("console_sessions").UpdateOne(ctx,
|
||||
bson.M{"session_id": sessionID, "org_id": orgID},
|
||||
bson.M{"session_id": sessionID, "instance_id": instanceID},
|
||||
bson.M{"$set": bson.M{"ssh_username": username}})
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
func ConsumeSessionToken(orgID, sessionID string) error {
|
||||
func ConsumeSessionToken(instanceID, sessionID string) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
now := time.Now()
|
||||
res, err := db.Col("console_sessions").UpdateOne(ctx,
|
||||
bson.M{"session_id": sessionID, "org_id": orgID, "token_consumed_at": nil},
|
||||
bson.M{"session_id": sessionID, "instance_id": instanceID, "token_consumed_at": nil},
|
||||
bson.M{"$set": bson.M{"token_consumed_at": now}},
|
||||
)
|
||||
if err != nil {
|
||||
@@ -239,12 +224,12 @@ func ConsumeSessionToken(orgID, sessionID string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func EndConsoleSession(orgID, sessionID string) error {
|
||||
func EndConsoleSession(instanceID, sessionID string) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
now := time.Now()
|
||||
_, err := db.Col("console_sessions").UpdateOne(ctx,
|
||||
bson.M{"session_id": sessionID, "org_id": orgID, "ended_at": nil},
|
||||
bson.M{"session_id": sessionID, "instance_id": instanceID, "ended_at": nil},
|
||||
bson.M{"$set": bson.M{"ended_at": now}},
|
||||
)
|
||||
return err
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/mrhid6/vantage/server/internal/db"
|
||||
"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"
|
||||
)
|
||||
|
||||
// EnsureAuthIndexes declares the indexes tenant isolation depends on.
|
||||
//
|
||||
// It lives here rather than in migrate.go so that the org-to-instance rename
|
||||
// could touch it without touching migrations 0001 to 0003, which deliberately
|
||||
// still speak the pre-rename shape.
|
||||
//
|
||||
// It MUST run after MigrateOrgToInstance. Creating the instances.slug index
|
||||
// first would create an empty instances collection, and migration 0004 refuses
|
||||
// to rename orgs when instances already exists.
|
||||
func EnsureAuthIndexes() error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// users.email and instances.slug are declared in the shared module so the
|
||||
// control plane and sitesvc cannot disagree about them.
|
||||
if err := indexes.EnsureCoreIndexes(ctx, db.Database); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// instance_oidc is control-plane only, so its index stays here.
|
||||
if _, err := db.Col("instance_oidc").Indexes().CreateOne(ctx, mongo.IndexModel{
|
||||
Keys: bson.D{{Key: "instance_id", Value: 1}},
|
||||
Options: options.Index().SetUnique(true),
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -22,8 +22,6 @@ func encryptionKey() ([]byte, error) {
|
||||
return key, nil
|
||||
}
|
||||
|
||||
|
||||
|
||||
func encryptString(plaintext string) (string, error) {
|
||||
key, err := encryptionKey()
|
||||
if err != nil {
|
||||
@@ -45,7 +43,6 @@ func encryptString(plaintext string) (string, error) {
|
||||
return hex.EncodeToString(sealed), nil
|
||||
}
|
||||
|
||||
|
||||
func decryptString(ciphertextHex string) (string, error) {
|
||||
key, err := encryptionKey()
|
||||
if err != nil {
|
||||
|
||||
@@ -8,11 +8,11 @@ import (
|
||||
"github.com/google/uuid"
|
||||
"github.com/mrhid6/vantage/server/internal/db"
|
||||
"github.com/mrhid6/vantage/server/internal/models"
|
||||
"github.com/mrhid6/vantage/shared/provision"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo/options"
|
||||
)
|
||||
|
||||
|
||||
func DefaultStepsDir() string {
|
||||
dir := os.Getenv("VANTAGE_DEFAULT_STEPS_DIR")
|
||||
if dir == "" {
|
||||
@@ -22,9 +22,6 @@ func DefaultStepsDir() string {
|
||||
return dir
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
func readDefaultStepFiles() ([]models.WorkflowStep, error) {
|
||||
matches, err := filepath.Glob(filepath.Join(DefaultStepsDir(), "*.json"))
|
||||
if err != nil {
|
||||
@@ -41,7 +38,7 @@ func readDefaultStepFiles() ([]models.WorkflowStep, error) {
|
||||
continue
|
||||
}
|
||||
s.Source = "default"
|
||||
s.Slug = Slugify(s.Name)
|
||||
s.Slug = provision.Slugify(s.Name)
|
||||
if s.Slug == "" {
|
||||
continue
|
||||
}
|
||||
@@ -50,9 +47,7 @@ func readDefaultStepFiles() ([]models.WorkflowStep, error) {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
|
||||
|
||||
func SeedDefaultSteps(orgID string) (created, updated int, err error) {
|
||||
func SeedDefaultSteps(instanceID string) (created, updated int, err error) {
|
||||
steps, err := readDefaultStepFiles()
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
@@ -61,7 +56,7 @@ func SeedDefaultSteps(orgID string) (created, updated int, err error) {
|
||||
defer cancel()
|
||||
col := db.Col("workflow_steps")
|
||||
for _, s := range steps {
|
||||
filter := bson.M{"org_id": orgID, "slug": s.Slug, "source": "default"}
|
||||
filter := bson.M{"instance_id": instanceID, "slug": s.Slug, "source": "default"}
|
||||
set := bson.M{
|
||||
"name": s.Name,
|
||||
"description": s.Description,
|
||||
@@ -75,11 +70,11 @@ func SeedDefaultSteps(orgID string) (created, updated int, err error) {
|
||||
res, uerr := col.UpdateOne(ctx, filter, bson.M{
|
||||
"$set": set,
|
||||
"$setOnInsert": bson.M{
|
||||
"org_id": orgID,
|
||||
"step_id": uuid.New().String(),
|
||||
"slug": s.Slug,
|
||||
"source": "default",
|
||||
"created_at": time.Now(),
|
||||
"instance_id": instanceID,
|
||||
"step_id": uuid.New().String(),
|
||||
"slug": s.Slug,
|
||||
"source": "default",
|
||||
"created_at": time.Now(),
|
||||
},
|
||||
}, options.UpdateOne().SetUpsert(true))
|
||||
if uerr != nil {
|
||||
|
||||
@@ -17,13 +17,10 @@ type commandDispatcher struct {
|
||||
channels map[string]chan *pb.ServerCommand
|
||||
}
|
||||
|
||||
|
||||
|
||||
var Dispatcher = &commandDispatcher{
|
||||
channels: make(map[string]chan *pb.ServerCommand),
|
||||
}
|
||||
|
||||
|
||||
func (d *commandDispatcher) Connect(serverID string) chan *pb.ServerCommand {
|
||||
ch := make(chan *pb.ServerCommand, 16)
|
||||
d.mu.Lock()
|
||||
@@ -32,14 +29,12 @@ func (d *commandDispatcher) Connect(serverID string) chan *pb.ServerCommand {
|
||||
return ch
|
||||
}
|
||||
|
||||
|
||||
func (d *commandDispatcher) Disconnect(serverID string) {
|
||||
d.mu.Lock()
|
||||
delete(d.channels, serverID)
|
||||
d.mu.Unlock()
|
||||
}
|
||||
|
||||
|
||||
func (d *commandDispatcher) IsConnected(serverID string) bool {
|
||||
d.mu.RLock()
|
||||
_, ok := d.channels[serverID]
|
||||
@@ -62,15 +57,10 @@ func (d *commandDispatcher) dispatch(serverID string, cmd *pb.ServerCommand) err
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
func DispatchRunStep(serverID, commandID string, cmd *pb.RunStepCmd) error {
|
||||
return Dispatcher.dispatch(serverID, &pb.ServerCommand{CommandId: commandID, RunStep: cmd})
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
func DispatchCleanupWorkspace(serverID, workspaceID string) {
|
||||
if !Dispatcher.IsConnected(serverID) {
|
||||
return
|
||||
@@ -81,7 +71,6 @@ func DispatchCleanupWorkspace(serverID, workspaceID string) {
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
type KeyGenParams struct {
|
||||
Label string
|
||||
KeyType string
|
||||
@@ -90,15 +79,13 @@ type KeyGenParams struct {
|
||||
Comment string
|
||||
}
|
||||
|
||||
|
||||
|
||||
func GetLatestAgentVersion() (string, error) {
|
||||
giteaHost := os.Getenv("GITEA_HOST")
|
||||
if giteaHost == "" {
|
||||
giteaHost = "gitea.example.com"
|
||||
}
|
||||
url := fmt.Sprintf("https://%s/api/v1/repos/mrhid6/vantage/releases?limit=20", giteaHost)
|
||||
resp, err := http.Get(url)
|
||||
resp, err := http.Get(url)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("fetch releases: %w", err)
|
||||
}
|
||||
@@ -122,8 +109,6 @@ func GetLatestAgentVersion() (string, error) {
|
||||
return "", fmt.Errorf("no agent release found")
|
||||
}
|
||||
|
||||
|
||||
|
||||
func DispatchUpdateAgent(serverID string) (string, error) {
|
||||
if !Dispatcher.IsConnected(serverID) {
|
||||
return "", fmt.Errorf("agent is not connected to the command stream")
|
||||
@@ -153,7 +138,6 @@ func DispatchUpdateAgent(serverID string) (string, error) {
|
||||
return version, nil
|
||||
}
|
||||
|
||||
|
||||
func DispatchApplyUpdates(serverID string) error {
|
||||
if !Dispatcher.IsConnected(serverID) {
|
||||
return fmt.Errorf("agent is not connected to the command stream")
|
||||
@@ -165,8 +149,6 @@ func DispatchApplyUpdates(serverID string) error {
|
||||
return Dispatcher.dispatch(serverID, cmd)
|
||||
}
|
||||
|
||||
|
||||
|
||||
func DispatchDeleteKey(serverID, label string) {
|
||||
if !Dispatcher.IsConnected(serverID) {
|
||||
return
|
||||
@@ -176,13 +158,11 @@ func DispatchDeleteKey(serverID, label string) {
|
||||
DeleteKey: &pb.DeleteKeyCmd{Label: label},
|
||||
}
|
||||
if err := Dispatcher.dispatch(serverID, cmd); err != nil {
|
||||
|
||||
|
||||
_ = err
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
func DispatchGenerateKey(serverID string, p KeyGenParams) (string, error) {
|
||||
if !Dispatcher.IsConnected(serverID) {
|
||||
return "", fmt.Errorf("agent is not connected to the command stream")
|
||||
|
||||
@@ -10,32 +10,30 @@ import (
|
||||
"go.mongodb.org/mongo-driver/v2/mongo/options"
|
||||
)
|
||||
|
||||
func GetOrgOIDC(orgID string) (*models.OrgOIDC, error) {
|
||||
func GetInstanceOIDC(instanceID string) (*models.OrgOIDC, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
var o models.OrgOIDC
|
||||
err := db.Col("org_oidc").FindOne(ctx, bson.M{"org_id": orgID}).Decode(&o)
|
||||
err := db.Col("instance_oidc").FindOne(ctx, bson.M{"instance_id": instanceID}).Decode(&o)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &o, nil
|
||||
}
|
||||
|
||||
func GetOrgOIDCSecret(orgID string) (string, error) {
|
||||
o, err := GetOrgOIDC(orgID)
|
||||
func GetInstanceOIDCSecret(instanceID string) (string, error) {
|
||||
o, err := GetInstanceOIDC(instanceID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return decryptString(o.ClientSecretEnc)
|
||||
}
|
||||
|
||||
|
||||
|
||||
func SaveOrgOIDC(orgID, issuer, clientID, clientSecret string, enabled bool) error {
|
||||
func SaveOrgOIDC(instanceID, issuer, clientID, clientSecret string, enabled bool) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
set := bson.M{
|
||||
"org_id": orgID, "issuer": issuer, "client_id": clientID,
|
||||
"instance_id": instanceID, "issuer": issuer, "client_id": clientID,
|
||||
"enabled": enabled, "updated_at": time.Now(),
|
||||
}
|
||||
if clientSecret != "" {
|
||||
@@ -45,8 +43,8 @@ func SaveOrgOIDC(orgID, issuer, clientID, clientSecret string, enabled bool) err
|
||||
}
|
||||
set["client_secret_enc"] = enc
|
||||
}
|
||||
_, err := db.Col("org_oidc").UpdateOne(ctx,
|
||||
bson.M{"org_id": orgID}, bson.M{"$set": set},
|
||||
_, err := db.Col("instance_oidc").UpdateOne(ctx,
|
||||
bson.M{"instance_id": instanceID}, bson.M{"$set": set},
|
||||
options.UpdateOne().SetUpsert(true))
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/mrhid6/vantage/server/internal/db"
|
||||
"github.com/mrhid6/vantage/server/internal/models"
|
||||
"github.com/mrhid6/vantage/shared/provision"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
)
|
||||
|
||||
func GetInstance(instanceID string) (*models.Instance, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
var o models.Instance
|
||||
err := db.Col("instances").FindOne(ctx, bson.M{"instance_id": instanceID}).Decode(&o)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &o, nil
|
||||
}
|
||||
|
||||
func GetInstanceBySlug(slug string) (*models.Instance, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
var o models.Instance
|
||||
err := db.Col("instances").FindOne(ctx, bson.M{"slug": slug}).Decode(&o)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &o, nil
|
||||
}
|
||||
|
||||
func ListInstanceIDs() ([]string, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
cursor, err := db.Col("instances").Find(ctx, bson.M{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer cursor.Close(ctx)
|
||||
var orgs []models.Instance
|
||||
if err := cursor.All(ctx, &orgs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ids := make([]string, 0, len(orgs))
|
||||
for _, o := range orgs {
|
||||
ids = append(ids, o.InstanceID)
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
func CountInstances() (int64, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
return db.Col("instances").CountDocuments(ctx, bson.M{})
|
||||
}
|
||||
|
||||
func FirstInstance() (*models.Instance, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
var o models.Instance
|
||||
if err := db.Col("instances").FindOne(ctx, bson.M{}).Decode(&o); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &o, nil
|
||||
}
|
||||
|
||||
func AdoptInstance(instanceID, name string) (*models.Instance, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
set := bson.M{"name": name}
|
||||
|
||||
slug := provision.Slugify(name)
|
||||
if len(slug) > provision.MaxSlugLength {
|
||||
slug = slug[:provision.MaxSlugLength]
|
||||
}
|
||||
if len(slug) >= provision.MinSlugLength && !provision.ReservedSlugs[slug] {
|
||||
n, err := db.Col("instances").CountDocuments(ctx, bson.M{"slug": slug, "instance_id": bson.M{"$ne": instanceID}})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if n == 0 {
|
||||
set["slug"] = slug
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := db.Col("instances").UpdateOne(ctx, bson.M{"instance_id": instanceID}, bson.M{"$set": set}); err != nil {
|
||||
if mongo.IsDuplicateKeyError(err) {
|
||||
return nil, fmt.Errorf("organization slug already taken")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return GetInstance(instanceID)
|
||||
}
|
||||
|
||||
// CreateInstance creates an organisation and seeds its default workflow steps.
|
||||
//
|
||||
// The creation rules live in shared/provision because sitesvc creates
|
||||
// organisations too. Seeding stays here: shared must not know about workflow
|
||||
// steps.
|
||||
func CreateInstance(name string) (*models.Instance, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
o, err := provision.CreateInstance(ctx, db.Database, name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if created, updated, err := SeedDefaultSteps(o.InstanceID); err != nil {
|
||||
log.Printf("warning: failed to seed default steps for new org %s: %v", o.InstanceID, err)
|
||||
} else {
|
||||
log.Printf("default steps seeded for new org %s: %d created, %d updated", o.InstanceID, created, updated)
|
||||
}
|
||||
return o, nil
|
||||
}
|
||||
@@ -9,8 +9,6 @@ import (
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
|
||||
|
||||
func StoreInventory(serverID string, r *pb.InventoryReport) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
@@ -36,9 +36,9 @@ func setKeyMeta(k *models.Key) {
|
||||
k.HasPassphrase = k.PassphraseEncrypted != ""
|
||||
}
|
||||
|
||||
func CreateKey(orgID, label, publicKey, source, generatedByServerID, privateKey, passphrase string) (*models.Key, error) {
|
||||
func CreateKey(instanceID, label, publicKey, source, generatedByServerID, privateKey, passphrase string) (*models.Key, error) {
|
||||
key := &models.Key{
|
||||
OrgID: orgID,
|
||||
InstanceID: instanceID,
|
||||
KeyID: uuid.NewString(),
|
||||
Label: label,
|
||||
PublicKey: publicKey,
|
||||
@@ -72,12 +72,12 @@ func CreateKey(orgID, label, publicKey, source, generatedByServerID, privateKey,
|
||||
return key, nil
|
||||
}
|
||||
|
||||
func GetKey(orgID, keyID string) (*models.Key, error) {
|
||||
func GetKey(instanceID, keyID string) (*models.Key, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var key models.Key
|
||||
err := db.Col("keys").FindOne(ctx, bson.M{"key_id": keyID, "org_id": orgID}).Decode(&key)
|
||||
err := db.Col("keys").FindOne(ctx, bson.M{"key_id": keyID, "instance_id": instanceID}).Decode(&key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -85,12 +85,12 @@ func GetKey(orgID, keyID string) (*models.Key, error) {
|
||||
return &key, nil
|
||||
}
|
||||
|
||||
func GetPrivateKey(orgID, keyID string) (string, error) {
|
||||
func GetPrivateKey(instanceID, keyID string) (string, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var key models.Key
|
||||
if err := db.Col("keys").FindOne(ctx, bson.M{"key_id": keyID, "org_id": orgID}).Decode(&key); err != nil {
|
||||
if err := db.Col("keys").FindOne(ctx, bson.M{"key_id": keyID, "instance_id": instanceID}).Decode(&key); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if key.PrivateKeyEncrypted == "" {
|
||||
@@ -118,11 +118,11 @@ type KeyWithCount struct {
|
||||
AssignedCount int `bson:"-" json:"assigned_count"`
|
||||
}
|
||||
|
||||
func ListKeys(orgID string) ([]KeyWithCount, error) {
|
||||
func ListKeys(instanceID string) ([]KeyWithCount, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
cursor, err := db.Col("keys").Find(ctx, bson.M{"org_id": orgID})
|
||||
cursor, err := db.Col("keys").Find(ctx, bson.M{"instance_id": instanceID})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -137,28 +137,28 @@ func ListKeys(orgID string) ([]KeyWithCount, error) {
|
||||
for _, k := range keys {
|
||||
setKeyMeta(&k)
|
||||
count, _ := db.Col("assignments").CountDocuments(ctx, bson.M{
|
||||
"org_id": orgID,
|
||||
"key_id": k.KeyID,
|
||||
"revoked_at": nil,
|
||||
"instance_id": instanceID,
|
||||
"key_id": k.KeyID,
|
||||
"revoked_at": nil,
|
||||
})
|
||||
result = append(result, KeyWithCount{Key: k, AssignedCount: int(count)})
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func DeleteKey(orgID, keyID string) error {
|
||||
func DeleteKey(instanceID, keyID string) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var key models.Key
|
||||
if err := db.Col("keys").FindOne(ctx, bson.M{"key_id": keyID, "org_id": orgID}).Decode(&key); err != nil {
|
||||
if err := db.Col("keys").FindOne(ctx, bson.M{"key_id": keyID, "instance_id": instanceID}).Decode(&key); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := db.Col("keys").DeleteOne(ctx, bson.M{"key_id": keyID, "org_id": orgID}); err != nil {
|
||||
if _, err := db.Col("keys").DeleteOne(ctx, bson.M{"key_id": keyID, "instance_id": instanceID}); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := db.Col("assignments").DeleteMany(ctx, bson.M{"key_id": keyID, "org_id": orgID}); err != nil {
|
||||
if _, err := db.Col("assignments").DeleteMany(ctx, bson.M{"key_id": keyID, "instance_id": instanceID}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -168,31 +168,30 @@ func DeleteKey(orgID, keyID string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func AssignKey(orgID, keyID, serverID string) (*models.Assignment, error) {
|
||||
func AssignKey(instanceID, keyID, serverID string) (*models.Assignment, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if _, err := GetKey(orgID, keyID); err != nil {
|
||||
if _, err := GetKey(instanceID, keyID); err != nil {
|
||||
return nil, fmt.Errorf("key not found")
|
||||
}
|
||||
if _, err := GetServer(orgID, serverID); err != nil {
|
||||
if _, err := GetServer(instanceID, serverID); err != nil {
|
||||
return nil, fmt.Errorf("server not found")
|
||||
}
|
||||
|
||||
|
||||
var existing models.Assignment
|
||||
err := db.Col("assignments").FindOne(ctx, bson.M{
|
||||
"org_id": orgID,
|
||||
"key_id": keyID,
|
||||
"server_id": serverID,
|
||||
"revoked_at": nil,
|
||||
"instance_id": instanceID,
|
||||
"key_id": keyID,
|
||||
"server_id": serverID,
|
||||
"revoked_at": nil,
|
||||
}).Decode(&existing)
|
||||
if err == nil {
|
||||
return &existing, nil
|
||||
}
|
||||
|
||||
a := &models.Assignment{
|
||||
OrgID: orgID,
|
||||
InstanceID: instanceID,
|
||||
KeyID: keyID,
|
||||
ServerID: serverID,
|
||||
AssignedAt: time.Now(),
|
||||
@@ -204,23 +203,23 @@ func AssignKey(orgID, keyID, serverID string) (*models.Assignment, error) {
|
||||
return a, nil
|
||||
}
|
||||
|
||||
func RevokeAssignment(orgID, keyID, serverID string) error {
|
||||
func RevokeAssignment(instanceID, keyID, serverID string) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
now := time.Now()
|
||||
_, err := db.Col("assignments").UpdateOne(ctx,
|
||||
bson.M{"org_id": orgID, "key_id": keyID, "server_id": serverID, "revoked_at": nil},
|
||||
bson.M{"instance_id": instanceID, "key_id": keyID, "server_id": serverID, "revoked_at": nil},
|
||||
bson.M{"$set": bson.M{"revoked_at": now}},
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func GetAssignmentsForKey(orgID, keyID string) ([]models.Assignment, error) {
|
||||
func GetAssignmentsForKey(instanceID, keyID string) ([]models.Assignment, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
cursor, err := db.Col("assignments").Find(ctx, bson.M{"org_id": orgID, "key_id": keyID, "revoked_at": nil})
|
||||
cursor, err := db.Col("assignments").Find(ctx, bson.M{"instance_id": instanceID, "key_id": keyID, "revoked_at": nil})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -238,11 +237,11 @@ type AssignmentWithServer struct {
|
||||
Server *models.Server `json:"server,omitempty"`
|
||||
}
|
||||
|
||||
func GetAssignmentsWithServers(orgID, keyID string) ([]AssignmentWithServer, error) {
|
||||
func GetAssignmentsWithServers(instanceID, keyID string) ([]AssignmentWithServer, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
cursor, err := db.Col("assignments").Find(ctx, bson.M{"org_id": orgID, "key_id": keyID})
|
||||
cursor, err := db.Col("assignments").Find(ctx, bson.M{"instance_id": instanceID, "key_id": keyID})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -257,7 +256,7 @@ func GetAssignmentsWithServers(orgID, keyID string) ([]AssignmentWithServer, err
|
||||
for _, a := range assignments {
|
||||
item := AssignmentWithServer{Assignment: a}
|
||||
var srv models.Server
|
||||
if err := db.Col("servers").FindOne(ctx, bson.M{"server_id": a.ServerID, "org_id": orgID}).Decode(&srv); err == nil {
|
||||
if err := db.Col("servers").FindOne(ctx, bson.M{"server_id": a.ServerID, "instance_id": instanceID}).Decode(&srv); err == nil {
|
||||
item.Server = &srv
|
||||
}
|
||||
result = append(result, item)
|
||||
@@ -270,11 +269,11 @@ type AssignmentWithKey struct {
|
||||
Key *models.Key `json:"key,omitempty"`
|
||||
}
|
||||
|
||||
func GetAssignmentsWithKeysForServer(orgID, serverID string) ([]AssignmentWithKey, error) {
|
||||
func GetAssignmentsWithKeysForServer(instanceID, serverID string) ([]AssignmentWithKey, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
cursor, err := db.Col("assignments").Find(ctx, bson.M{"org_id": orgID, "server_id": serverID})
|
||||
cursor, err := db.Col("assignments").Find(ctx, bson.M{"instance_id": instanceID, "server_id": serverID})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -288,7 +287,7 @@ func GetAssignmentsWithKeysForServer(orgID, serverID string) ([]AssignmentWithKe
|
||||
result := make([]AssignmentWithKey, 0, len(assignments))
|
||||
for _, a := range assignments {
|
||||
var key models.Key
|
||||
if err := db.Col("keys").FindOne(ctx, bson.M{"key_id": a.KeyID, "org_id": orgID}).Decode(&key); err != nil {
|
||||
if err := db.Col("keys").FindOne(ctx, bson.M{"key_id": a.KeyID, "instance_id": instanceID}).Decode(&key); err != nil {
|
||||
continue
|
||||
}
|
||||
setKeyMeta(&key)
|
||||
|
||||
@@ -8,51 +8,36 @@ import (
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/mrhid6/vantage/server/internal/db"
|
||||
"github.com/mrhid6/vantage/server/internal/models"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo/options"
|
||||
)
|
||||
|
||||
var scopedCollections = []string{
|
||||
// legacyOrg is the pre-0004 shape of the orgs collection.
|
||||
//
|
||||
// Migrations 0001 to 0003 run BEFORE the org-to-instance rename and must keep
|
||||
// reading and writing org_id in the orgs collection. They deliberately do not
|
||||
// use shared/models, which has moved on to Instance and instance_id.
|
||||
type legacyOrg struct {
|
||||
OrgID string `bson:"org_id"`
|
||||
Name string `bson:"name"`
|
||||
Slug string `bson:"slug"`
|
||||
CreatedAt time.Time `bson:"created_at"`
|
||||
}
|
||||
|
||||
var backfillCollections = []string{
|
||||
"servers", "keys", "assignments", "secrets",
|
||||
"workflows", "workflow_steps", "workflow_runs",
|
||||
"audit_logs", "monitors", "notification_channels",
|
||||
"console_sessions", "incidents", "monitor_rollups",
|
||||
}
|
||||
|
||||
func EnsureAuthIndexes() error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if _, err := db.Col("users").Indexes().CreateOne(ctx, mongo.IndexModel{
|
||||
Keys: bson.D{{Key: "email", Value: 1}},
|
||||
Options: options.Index().SetUnique(true),
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := db.Col("orgs").Indexes().CreateOne(ctx, mongo.IndexModel{
|
||||
Keys: bson.D{{Key: "slug", Value: 1}},
|
||||
Options: options.Index().SetUnique(true),
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := db.Col("org_oidc").Indexes().CreateOne(ctx, mongo.IndexModel{
|
||||
Keys: bson.D{{Key: "org_id", Value: 1}},
|
||||
Options: options.Index().SetUnique(true),
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func defaultBackfillOrg(ctx context.Context) (*models.Org, error) {
|
||||
var org models.Org
|
||||
func defaultBackfillOrg(ctx context.Context) (*legacyOrg, error) {
|
||||
var org legacyOrg
|
||||
err := db.Col("orgs").FindOne(ctx, bson.M{"slug": "default"}).Decode(&org)
|
||||
switch {
|
||||
case err == nil:
|
||||
case errors.Is(err, mongo.ErrNoDocuments):
|
||||
org = models.Org{OrgID: uuid.NewString(), Name: "Default", Slug: "default", CreatedAt: time.Now()}
|
||||
org = legacyOrg{OrgID: uuid.NewString(), Name: "Default", Slug: "default", CreatedAt: time.Now()}
|
||||
if _, err := db.Col("orgs").InsertOne(ctx, org); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -71,9 +56,8 @@ func RunMigrations() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
needs := false
|
||||
for _, col := range scopedCollections {
|
||||
for _, col := range backfillCollections {
|
||||
n, _ := db.Col(col).CountDocuments(ctx, bson.M{"org_id": bson.M{"$exists": false}})
|
||||
if n > 0 {
|
||||
needs = true
|
||||
@@ -86,7 +70,7 @@ func RunMigrations() error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, col := range scopedCollections {
|
||||
for _, col := range backfillCollections {
|
||||
if _, err := db.Col(col).UpdateMany(ctx,
|
||||
bson.M{"org_id": bson.M{"$exists": false}},
|
||||
bson.M{"$set": bson.M{"org_id": org.OrgID}},
|
||||
@@ -109,7 +93,6 @@ func MigrateMissedOrgScopes() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
missed := []string{"audit_logs", "notification_channels"}
|
||||
needs := false
|
||||
for _, col := range missed {
|
||||
@@ -190,7 +173,7 @@ func MigrateSettingsOrg() error {
|
||||
|
||||
n, _ := db.Col("settings").CountDocuments(ctx, bson.M{"org_id": bson.M{"$exists": false}})
|
||||
if n > 0 {
|
||||
var org models.Org
|
||||
var org legacyOrg
|
||||
orgCount, err := db.Col("orgs").CountDocuments(ctx, bson.M{})
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -201,7 +184,7 @@ func MigrateSettingsOrg() error {
|
||||
return err
|
||||
}
|
||||
case 0:
|
||||
org = models.Org{OrgID: uuid.NewString(), Name: "Default", Slug: "default", CreatedAt: time.Now()}
|
||||
org = legacyOrg{OrgID: uuid.NewString(), Name: "Default", Slug: "default", CreatedAt: time.Now()}
|
||||
if _, err := db.Col("orgs").InsertOne(ctx, org); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
)
|
||||
|
||||
// ScopedCollections lists every collection carrying the tenant key.
|
||||
//
|
||||
// Migration 0004 renames org_id to instance_id in each. A collection missing
|
||||
// from this list keeps the old field name and becomes invisible to every scoped
|
||||
// query — so this list is load-bearing, not documentation.
|
||||
//
|
||||
// AssertNoScopedCollectionMissed checks at boot that nothing outside this list
|
||||
// holds an org_id.
|
||||
//
|
||||
// The migrations collection is deliberately absent: it is not tenant-scoped.
|
||||
// The two renamed collections appear under their post-rename names, because the
|
||||
// migration renames the collections before it renames the field.
|
||||
var ScopedCollections = []string{
|
||||
"instances",
|
||||
"servers",
|
||||
"keys",
|
||||
"assignments",
|
||||
"users",
|
||||
"instance_oidc",
|
||||
"settings",
|
||||
"secrets",
|
||||
"workflows",
|
||||
"workflow_steps",
|
||||
"workflow_runs",
|
||||
"monitors",
|
||||
"incidents",
|
||||
"monitor_rollups",
|
||||
"notification_channels",
|
||||
"console_sessions",
|
||||
"audit_logs",
|
||||
}
|
||||
|
||||
// collectionRenames maps the two collections whose names change. Ordered so the
|
||||
// migration is deterministic.
|
||||
var collectionRenames = []struct{ from, to string }{
|
||||
{"orgs", "instances"},
|
||||
{"org_oidc", "instance_oidc"},
|
||||
}
|
||||
|
||||
// MigrateOrgToInstance renames the tenant key from org_id to instance_id.
|
||||
//
|
||||
// It only ever renames documents. It never deletes, drops or unsets one, so a
|
||||
// bad deploy is recovered by running the inverse rename (cmd/rename-rollback)
|
||||
// rather than by restoring a backup.
|
||||
//
|
||||
// The steps are not atomic across collections — multi-document transactions
|
||||
// would require a replica set, which self-hosted installs do not guarantee.
|
||||
// Instead every step is safely repeatable: a collection rename is skipped when
|
||||
// the source is already gone, and $rename matches nothing on a document that
|
||||
// has already been renamed. A run that fails partway is fixed by running it
|
||||
// again.
|
||||
//
|
||||
// It must run BEFORE EnsureAuthIndexes. Creating the instances.slug index first
|
||||
// would create an empty instances collection, and step 1 below refuses to
|
||||
// rename orgs onto an existing target.
|
||||
func MigrateOrgToInstance(ctx context.Context, db *mongo.Database) error {
|
||||
names, err := db.ListCollectionNames(ctx, bson.M{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("list collections: %w", err)
|
||||
}
|
||||
exists := map[string]bool{}
|
||||
for _, n := range names {
|
||||
exists[n] = true
|
||||
}
|
||||
|
||||
// Step 1: rename the collections.
|
||||
for _, r := range collectionRenames {
|
||||
switch {
|
||||
case !exists[r.from]:
|
||||
// Nothing to rename: either already done or never existed.
|
||||
continue
|
||||
case exists[r.to]:
|
||||
return fmt.Errorf("cannot rename %s to %s: both exist; resolve by hand", r.from, r.to)
|
||||
}
|
||||
cmd := bson.D{
|
||||
{Key: "renameCollection", Value: db.Name() + "." + r.from},
|
||||
{Key: "to", Value: db.Name() + "." + r.to},
|
||||
}
|
||||
if err := db.Client().Database("admin").RunCommand(ctx, cmd).Err(); err != nil {
|
||||
return fmt.Errorf("rename %s to %s: %w", r.from, r.to, err)
|
||||
}
|
||||
log.Printf("0004: renamed collection %s to %s", r.from, r.to)
|
||||
}
|
||||
|
||||
// Step 2: drop indexes keyed on the old field name, BEFORE renaming it.
|
||||
//
|
||||
// Order matters and is not obvious. A unique index on org_id treats a
|
||||
// missing org_id as null, so as soon as $rename strips the field from the
|
||||
// second document the index reports a duplicate null and the whole update
|
||||
// fails. Dropping first avoids that entirely.
|
||||
//
|
||||
// Dropping an index touches no documents. The boot-time index builders
|
||||
// recreate the current ones against the new field name.
|
||||
for _, c := range ScopedCollections {
|
||||
if err := DropIndexesKeyedOn(ctx, db, c, "org_id"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3: rename the field.
|
||||
for _, c := range ScopedCollections {
|
||||
res, err := db.Collection(c).UpdateMany(ctx,
|
||||
bson.M{"org_id": bson.M{"$exists": true}},
|
||||
bson.M{"$rename": bson.M{"org_id": "instance_id"}},
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("rename org_id in %s: %w", c, err)
|
||||
}
|
||||
if res.ModifiedCount > 0 {
|
||||
log.Printf("0004: %s renamed %d document(s)", c, res.ModifiedCount)
|
||||
}
|
||||
}
|
||||
|
||||
// Step 4: verify before anyone records a marker. Any mismatch aborts, and
|
||||
// the migration is re-run rather than marked done.
|
||||
for _, c := range ScopedCollections {
|
||||
total, err := db.Collection(c).CountDocuments(ctx, bson.M{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("count %s: %w", c, err)
|
||||
}
|
||||
if total == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
stale, err := db.Collection(c).CountDocuments(ctx, bson.M{"org_id": bson.M{"$exists": true}})
|
||||
if err != nil {
|
||||
return fmt.Errorf("count stale in %s: %w", c, err)
|
||||
}
|
||||
if stale != 0 {
|
||||
return fmt.Errorf("%s still has %d document(s) with org_id; migration incomplete", c, stale)
|
||||
}
|
||||
|
||||
scoped, err := db.Collection(c).CountDocuments(ctx, bson.M{"instance_id": bson.M{"$exists": true}})
|
||||
if err != nil {
|
||||
return fmt.Errorf("count scoped in %s: %w", c, err)
|
||||
}
|
||||
if scoped != total {
|
||||
return fmt.Errorf("%s has %d document(s) but only %d carry instance_id", c, total, scoped)
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("0004: verified %d collection(s)", len(ScopedCollections))
|
||||
return nil
|
||||
}
|
||||
|
||||
// IndexKeyedOn reports whether an index specification's key document mentions
|
||||
// field.
|
||||
//
|
||||
// The key is checked as both bson.D and bson.M because the driver's decoding of
|
||||
// a nested document depends on the target type, and getting this wrong is
|
||||
// silent: the index simply is not found, and the field rename then fails on a
|
||||
// duplicate null.
|
||||
func IndexKeyedOn(key any, field string) bool {
|
||||
switch k := key.(type) {
|
||||
case bson.M:
|
||||
_, ok := k[field]
|
||||
return ok
|
||||
case bson.D:
|
||||
for _, e := range k {
|
||||
if e.Key == field {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// DropIndexesKeyedOn removes every index on coll whose key mentions field,
|
||||
// leaving _id_ alone.
|
||||
//
|
||||
// Both the migration and its rollback must do this BEFORE renaming the field. A
|
||||
// unique index treats a missing field as null, so as soon as $rename strips the
|
||||
// field from the second document the index reports a duplicate null and the
|
||||
// whole update fails. Dropping an index touches no documents; the boot-time
|
||||
// index builders recreate what is needed.
|
||||
func DropIndexesKeyedOn(ctx context.Context, db *mongo.Database, coll, field string) error {
|
||||
cur, err := db.Collection(coll).Indexes().List(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("list indexes on %s: %w", coll, err)
|
||||
}
|
||||
var specs []bson.M
|
||||
if err := cur.All(ctx, &specs); err != nil {
|
||||
return fmt.Errorf("decode indexes on %s: %w", coll, err)
|
||||
}
|
||||
for _, s := range specs {
|
||||
name, _ := s["name"].(string)
|
||||
if name == "_id_" {
|
||||
continue
|
||||
}
|
||||
if !IndexKeyedOn(s["key"], field) {
|
||||
continue
|
||||
}
|
||||
if err := db.Collection(coll).Indexes().DropOne(ctx, name); err != nil {
|
||||
return fmt.Errorf("drop index %s on %s: %w", name, coll, err)
|
||||
}
|
||||
log.Printf("dropped stale index %s on %s", name, coll)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AssertNoScopedCollectionMissed reports any collection holding an org_id that
|
||||
// ScopedCollections does not know about. A hit means a collection was added
|
||||
// without being added to the list, and its tenant key was never renamed.
|
||||
func AssertNoScopedCollectionMissed(ctx context.Context, db *mongo.Database) error {
|
||||
known := map[string]bool{}
|
||||
for _, c := range ScopedCollections {
|
||||
known[c] = true
|
||||
}
|
||||
|
||||
names, err := db.ListCollectionNames(ctx, bson.M{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("list collections: %w", err)
|
||||
}
|
||||
|
||||
for _, n := range names {
|
||||
if known[n] {
|
||||
continue
|
||||
}
|
||||
count, err := db.Collection(n).CountDocuments(ctx, bson.M{"org_id": bson.M{"$exists": true}})
|
||||
if err != nil {
|
||||
return fmt.Errorf("count %s: %w", n, err)
|
||||
}
|
||||
if count > 0 {
|
||||
return fmt.Errorf("collection %q holds %d document(s) with org_id but is not in ScopedCollections", n, count)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -21,7 +21,6 @@ func monCtx() (context.Context, context.CancelFunc) {
|
||||
return context.WithTimeout(context.Background(), 5*time.Second)
|
||||
}
|
||||
|
||||
|
||||
func SpecFor(m *models.Monitor) checker.Spec {
|
||||
return checker.Spec{
|
||||
Type: m.Type,
|
||||
@@ -37,10 +36,10 @@ func SpecFor(m *models.Monitor) checker.Spec {
|
||||
}
|
||||
}
|
||||
|
||||
func ListMonitors(orgID string) ([]models.Monitor, error) {
|
||||
func ListMonitors(instanceID string) ([]models.Monitor, error) {
|
||||
ctx, cancel := monCtx()
|
||||
defer cancel()
|
||||
cur, err := db.Col("monitors").Find(ctx, bson.M{"org_id": orgID}, options.Find().SetSort(bson.M{"created_at": 1}))
|
||||
cur, err := db.Col("monitors").Find(ctx, bson.M{"instance_id": instanceID}, options.Find().SetSort(bson.M{"created_at": 1}))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -51,23 +50,23 @@ func ListMonitors(orgID string) ([]models.Monitor, error) {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func ListMonitorsForRunner(orgID, runner string) ([]models.Monitor, error) {
|
||||
if orgID == "" {
|
||||
func ListMonitorsForRunner(instanceID, runner string) ([]models.Monitor, error) {
|
||||
if instanceID == "" {
|
||||
return nil, errors.New("org id required")
|
||||
}
|
||||
return listMonitorsForRunner(orgID, runner)
|
||||
return listMonitorsForRunner(instanceID, runner)
|
||||
}
|
||||
|
||||
func ListServerScheduledMonitors() ([]models.Monitor, error) {
|
||||
return listMonitorsForRunner("", models.RunnerServer)
|
||||
}
|
||||
|
||||
func listMonitorsForRunner(orgID, runner string) ([]models.Monitor, error) {
|
||||
func listMonitorsForRunner(instanceID, runner string) ([]models.Monitor, error) {
|
||||
ctx, cancel := monCtx()
|
||||
defer cancel()
|
||||
filter := bson.M{"runner": runner, "enabled": true}
|
||||
if orgID != "" {
|
||||
filter["org_id"] = orgID
|
||||
if instanceID != "" {
|
||||
filter["instance_id"] = instanceID
|
||||
}
|
||||
cur, err := db.Col("monitors").Find(ctx, filter)
|
||||
if err != nil {
|
||||
@@ -80,12 +79,11 @@ func listMonitorsForRunner(orgID, runner string) ([]models.Monitor, error) {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
|
||||
func GetMonitor(orgID, monitorID string) (*models.Monitor, error) {
|
||||
func GetMonitor(instanceID, monitorID string) (*models.Monitor, error) {
|
||||
ctx, cancel := monCtx()
|
||||
defer cancel()
|
||||
var m models.Monitor
|
||||
err := db.Col("monitors").FindOne(ctx, bson.M{"monitor_id": monitorID, "org_id": orgID}).Decode(&m)
|
||||
err := db.Col("monitors").FindOne(ctx, bson.M{"monitor_id": monitorID, "instance_id": instanceID}).Decode(&m)
|
||||
if errors.Is(err, mongo.ErrNoDocuments) {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -109,26 +107,26 @@ func getMonitorByID(monitorID string) (*models.Monitor, error) {
|
||||
return &m, nil
|
||||
}
|
||||
|
||||
func validateRunner(orgID, runner string) error {
|
||||
func validateRunner(instanceID, runner string) error {
|
||||
if runner == "" || runner == models.RunnerServer {
|
||||
return nil
|
||||
}
|
||||
if _, err := GetServer(orgID, runner); err != nil {
|
||||
if _, err := GetServer(instanceID, runner); err != nil {
|
||||
return fmt.Errorf("runner server %s not found", runner)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func CreateMonitor(orgID string, m *models.Monitor) (*models.Monitor, error) {
|
||||
func CreateMonitor(instanceID string, m *models.Monitor) (*models.Monitor, error) {
|
||||
ctx, cancel := monCtx()
|
||||
defer cancel()
|
||||
if err := validateChannelIDs(orgID, m.ChannelIDs); err != nil {
|
||||
if err := validateChannelIDs(instanceID, m.ChannelIDs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateRunner(orgID, m.Runner); err != nil {
|
||||
if err := validateRunner(instanceID, m.Runner); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m.OrgID = orgID
|
||||
m.InstanceID = instanceID
|
||||
m.MonitorID = uuid.NewString()
|
||||
m.CreatedAt = time.Now()
|
||||
if m.IntervalSec <= 0 {
|
||||
@@ -147,17 +145,16 @@ func CreateMonitor(orgID string, m *models.Monitor) (*models.Monitor, error) {
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func UpdateMonitor(orgID, monitorID string, upd bson.M) error {
|
||||
func UpdateMonitor(instanceID, monitorID string, upd bson.M) error {
|
||||
ctx, cancel := monCtx()
|
||||
defer cancel()
|
||||
|
||||
|
||||
|
||||
if raw, present := upd["channel_ids"]; present {
|
||||
ids, ok := raw.([]string)
|
||||
if !ok {
|
||||
return fmt.Errorf("channel_ids must be a string array")
|
||||
}
|
||||
if err := validateChannelIDs(orgID, ids); err != nil {
|
||||
if err := validateChannelIDs(instanceID, ids); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -166,43 +163,41 @@ func UpdateMonitor(orgID, monitorID string, upd bson.M) error {
|
||||
if !ok {
|
||||
return fmt.Errorf("runner must be a string")
|
||||
}
|
||||
if err := validateRunner(orgID, runner); err != nil {
|
||||
if err := validateRunner(instanceID, runner); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
if runner == "" {
|
||||
upd["runner"] = models.RunnerServer
|
||||
}
|
||||
}
|
||||
_, err := db.Col("monitors").UpdateOne(ctx, bson.M{"monitor_id": monitorID, "org_id": orgID}, bson.M{"$set": upd})
|
||||
_, err := db.Col("monitors").UpdateOne(ctx, bson.M{"monitor_id": monitorID, "instance_id": instanceID}, bson.M{"$set": upd})
|
||||
return err
|
||||
}
|
||||
|
||||
func DeleteMonitor(orgID, monitorID string) error {
|
||||
func DeleteMonitor(instanceID, monitorID string) error {
|
||||
ctx, cancel := monCtx()
|
||||
defer cancel()
|
||||
res, err := db.Col("monitors").DeleteOne(ctx, bson.M{"monitor_id": monitorID, "org_id": orgID})
|
||||
res, err := db.Col("monitors").DeleteOne(ctx, bson.M{"monitor_id": monitorID, "instance_id": instanceID})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
if res.DeletedCount == 0 {
|
||||
return nil
|
||||
}
|
||||
db.Col("incidents").DeleteMany(ctx, bson.M{"monitor_id": monitorID, "org_id": orgID})
|
||||
db.Col("monitor_rollups").DeleteMany(ctx, bson.M{"monitor_id": monitorID, "org_id": orgID})
|
||||
db.Col("incidents").DeleteMany(ctx, bson.M{"monitor_id": monitorID, "instance_id": instanceID})
|
||||
db.Col("monitor_rollups").DeleteMany(ctx, bson.M{"monitor_id": monitorID, "instance_id": instanceID})
|
||||
return nil
|
||||
}
|
||||
|
||||
func ListIncidents(orgID, monitorID string, limit int64) ([]models.Incident, error) {
|
||||
func ListIncidents(instanceID, monitorID string, limit int64) ([]models.Incident, error) {
|
||||
ctx, cancel := monCtx()
|
||||
defer cancel()
|
||||
if limit <= 0 {
|
||||
limit = 50
|
||||
}
|
||||
cur, err := db.Col("incidents").Find(ctx, bson.M{"monitor_id": monitorID, "org_id": orgID},
|
||||
cur, err := db.Col("incidents").Find(ctx, bson.M{"monitor_id": monitorID, "instance_id": instanceID},
|
||||
options.Find().SetSort(bson.M{"started_at": -1}).SetLimit(limit))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -214,12 +209,11 @@ func ListIncidents(orgID, monitorID string, limit int64) ([]models.Incident, err
|
||||
return out, nil
|
||||
}
|
||||
|
||||
|
||||
func UptimeRollups(orgID, monitorID string, since time.Time) ([]models.Rollup, error) {
|
||||
func UptimeRollups(instanceID, monitorID string, since time.Time) ([]models.Rollup, error) {
|
||||
ctx, cancel := monCtx()
|
||||
defer cancel()
|
||||
cur, err := db.Col("monitor_rollups").Find(ctx,
|
||||
bson.M{"monitor_id": monitorID, "org_id": orgID, "period_start": bson.M{"$gte": since}},
|
||||
bson.M{"monitor_id": monitorID, "instance_id": instanceID, "period_start": bson.M{"$gte": since}},
|
||||
options.Find().SetSort(bson.M{"period_start": 1}))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -231,18 +225,18 @@ func UptimeRollups(orgID, monitorID string, since time.Time) ([]models.Rollup, e
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func IngestResult(orgID, runner, monitorID string, res checker.Result) error {
|
||||
if orgID == "" {
|
||||
func IngestResult(instanceID, runner, monitorID string, res checker.Result) error {
|
||||
if instanceID == "" {
|
||||
return errors.New("org id required")
|
||||
}
|
||||
return ingestResult(orgID, runner, monitorID, res)
|
||||
return ingestResult(instanceID, runner, monitorID, res)
|
||||
}
|
||||
|
||||
func IngestServerScheduledResult(monitorID string, res checker.Result) error {
|
||||
return ingestResult("", models.RunnerServer, monitorID, res)
|
||||
}
|
||||
|
||||
func ingestResult(orgID, runner, monitorID string, res checker.Result) error {
|
||||
func ingestResult(instanceID, runner, monitorID string, res checker.Result) error {
|
||||
ctx, cancel := monCtx()
|
||||
defer cancel()
|
||||
|
||||
@@ -253,7 +247,7 @@ func ingestResult(orgID, runner, monitorID string, res checker.Result) error {
|
||||
if m == nil {
|
||||
return fmt.Errorf("monitor %s not found", monitorID)
|
||||
}
|
||||
if orgID != "" && m.OrgID != orgID {
|
||||
if instanceID != "" && m.InstanceID != instanceID {
|
||||
return fmt.Errorf("monitor %s belongs to another org", monitorID)
|
||||
}
|
||||
if m.Runner != runner {
|
||||
@@ -295,28 +289,25 @@ func ingestResult(orgID, runner, monitorID string, res checker.Result) error {
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
bucket := now.Truncate(time.Hour)
|
||||
up := 0
|
||||
if res.Up {
|
||||
up = 1
|
||||
}
|
||||
|
||||
|
||||
|
||||
db.Col("monitor_rollups").UpdateOne(ctx,
|
||||
bson.M{"monitor_id": monitorID, "period_start": bucket},
|
||||
bson.M{
|
||||
"$inc": bson.M{"checks": 1, "up_count": up, "sum_latency": int64(res.LatencyMs)},
|
||||
"$setOnInsert": bson.M{"org_id": m.OrgID},
|
||||
"$setOnInsert": bson.M{"instance_id": m.InstanceID},
|
||||
},
|
||||
options.UpdateOne().SetUpsert(true))
|
||||
|
||||
|
||||
if newStatus != prev {
|
||||
switch newStatus {
|
||||
case models.StatusDown:
|
||||
inc := models.Incident{
|
||||
OrgID: m.OrgID,
|
||||
InstanceID: m.InstanceID,
|
||||
IncidentID: uuid.NewString(),
|
||||
MonitorID: monitorID,
|
||||
StartedAt: now,
|
||||
@@ -327,7 +318,7 @@ func ingestResult(orgID, runner, monitorID string, res checker.Result) error {
|
||||
case models.StatusUp:
|
||||
if prev == models.StatusDown {
|
||||
db.Col("incidents").UpdateOne(ctx,
|
||||
bson.M{"monitor_id": monitorID, "org_id": m.OrgID, "resolved_at": nil},
|
||||
bson.M{"monitor_id": monitorID, "instance_id": m.InstanceID, "resolved_at": nil},
|
||||
bson.M{"$set": bson.M{"resolved_at": now}})
|
||||
notifyTransition(m, newStatus, res.Message)
|
||||
}
|
||||
@@ -336,14 +327,11 @@ func ingestResult(orgID, runner, monitorID string, res checker.Result) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
func notifyTransition(m *models.Monitor, newStatus, message string) {
|
||||
if len(m.ChannelIDs) == 0 {
|
||||
return
|
||||
}
|
||||
channels, err := GetChannels(m.OrgID, m.ChannelIDs)
|
||||
channels, err := GetChannels(m.InstanceID, m.ChannelIDs)
|
||||
if err != nil {
|
||||
log.Printf("notify: load channels for %s: %v", m.MonitorID, err)
|
||||
return
|
||||
@@ -366,5 +354,5 @@ func notifyTransition(m *models.Monitor, newStatus, message string) {
|
||||
}
|
||||
}(ch)
|
||||
}
|
||||
_ = UpdateMonitor(m.OrgID, m.MonitorID, bson.M{"state.last_notified_at": time.Now()})
|
||||
_ = UpdateMonitor(m.InstanceID, m.MonitorID, bson.M{"state.last_notified_at": time.Now()})
|
||||
}
|
||||
|
||||
@@ -1,157 +0,0 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/mrhid6/vantage/server/internal/db"
|
||||
"github.com/mrhid6/vantage/server/internal/models"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
)
|
||||
|
||||
var reservedSlugs = map[string]bool{
|
||||
"www": true, "api": true, "app": true, "admin": true, "auth": true,
|
||||
"install": true, "static": true, "_next": true, "default": true,
|
||||
}
|
||||
|
||||
func GetOrg(orgID string) (*models.Org, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
var o models.Org
|
||||
err := db.Col("orgs").FindOne(ctx, bson.M{"org_id": orgID}).Decode(&o)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &o, nil
|
||||
}
|
||||
|
||||
func GetOrgBySlug(slug string) (*models.Org, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
var o models.Org
|
||||
err := db.Col("orgs").FindOne(ctx, bson.M{"slug": slug}).Decode(&o)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &o, nil
|
||||
}
|
||||
|
||||
|
||||
|
||||
func ListOrgIDs() ([]string, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
cursor, err := db.Col("orgs").Find(ctx, bson.M{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer cursor.Close(ctx)
|
||||
var orgs []models.Org
|
||||
if err := cursor.All(ctx, &orgs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ids := make([]string, 0, len(orgs))
|
||||
for _, o := range orgs {
|
||||
ids = append(ids, o.OrgID)
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
func CountOrgs() (int64, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
return db.Col("orgs").CountDocuments(ctx, bson.M{})
|
||||
}
|
||||
|
||||
func FirstOrg() (*models.Org, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
var o models.Org
|
||||
if err := db.Col("orgs").FindOne(ctx, bson.M{}).Decode(&o); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &o, nil
|
||||
}
|
||||
|
||||
func AdoptOrg(orgID, name string) (*models.Org, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
set := bson.M{"name": name}
|
||||
|
||||
slug := Slugify(name)
|
||||
if len(slug) > 40 {
|
||||
slug = slug[:40]
|
||||
}
|
||||
if len(slug) >= 3 && !reservedSlugs[slug] {
|
||||
n, err := db.Col("orgs").CountDocuments(ctx, bson.M{"slug": slug, "org_id": bson.M{"$ne": orgID}})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if n == 0 {
|
||||
set["slug"] = slug
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := db.Col("orgs").UpdateOne(ctx, bson.M{"org_id": orgID}, bson.M{"$set": set}); err != nil {
|
||||
if mongo.IsDuplicateKeyError(err) {
|
||||
return nil, fmt.Errorf("organization slug already taken")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return GetOrg(orgID)
|
||||
}
|
||||
|
||||
func CreateOrg(name string) (*models.Org, error) {
|
||||
base := Slugify(name)
|
||||
if len(base) < 3 {
|
||||
return nil, fmt.Errorf("organization name too short (slug must be >= 3 chars)")
|
||||
}
|
||||
if len(base) > 40 {
|
||||
base = base[:40]
|
||||
}
|
||||
if reservedSlugs[base] {
|
||||
return nil, fmt.Errorf("organization name is reserved")
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
|
||||
slug := base
|
||||
for i := 2; ; i++ {
|
||||
n, err := db.Col("orgs").CountDocuments(ctx, bson.M{"slug": slug})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if n == 0 {
|
||||
break
|
||||
}
|
||||
slug = fmt.Sprintf("%s-%d", base, i)
|
||||
}
|
||||
|
||||
o := &models.Org{OrgID: uuid.NewString(), Name: name, Slug: slug, CreatedAt: time.Now()}
|
||||
if _, err := db.Col("orgs").InsertOne(ctx, o); err != nil {
|
||||
if mongo.IsDuplicateKeyError(err) {
|
||||
return nil, fmt.Errorf("organization slug already taken")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
if created, updated, err := SeedDefaultSteps(o.OrgID); err != nil {
|
||||
log.Printf("warning: failed to seed default steps for new org %s: %v", o.OrgID, err)
|
||||
} else {
|
||||
log.Printf("default steps seeded for new org %s: %d created, %d updated", o.OrgID, created, updated)
|
||||
}
|
||||
return o, nil
|
||||
}
|
||||
@@ -23,7 +23,7 @@ func EnsureSecretIndexes() error {
|
||||
}
|
||||
|
||||
_, err := db.Col("secrets").Indexes().CreateOne(ctx, mongo.IndexModel{
|
||||
Keys: bson.D{{Key: "org_id", Value: 1}, {Key: "group", Value: 1}, {Key: "key", Value: 1}},
|
||||
Keys: bson.D{{Key: "instance_id", Value: 1}, {Key: "group", Value: 1}, {Key: "key", Value: 1}},
|
||||
Options: options.Index().SetUnique(true),
|
||||
})
|
||||
return err
|
||||
@@ -38,12 +38,12 @@ func isIndexNotFound(err error) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func ListSecretGroups(orgID string) ([]models.GroupSummary, error) {
|
||||
func ListSecretGroups(instanceID string) ([]models.GroupSummary, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
pipeline := mongo.Pipeline{
|
||||
{{Key: "$match", Value: bson.D{{Key: "org_id", Value: orgID}}}},
|
||||
{{Key: "$match", Value: bson.D{{Key: "instance_id", Value: instanceID}}}},
|
||||
{{Key: "$group", Value: bson.D{
|
||||
{Key: "_id", Value: "$group"},
|
||||
{Key: "key_count", Value: bson.D{{Key: "$sum", Value: 1}}},
|
||||
@@ -78,11 +78,11 @@ func ListSecretGroups(orgID string) ([]models.GroupSummary, error) {
|
||||
return groups, nil
|
||||
}
|
||||
|
||||
func GetSecretGroup(orgID, group string) ([]models.Secret, error) {
|
||||
func GetSecretGroup(instanceID, group string) ([]models.Secret, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
cursor, err := db.Col("secrets").Find(ctx, bson.M{"org_id": orgID, "group": group},
|
||||
cursor, err := db.Col("secrets").Find(ctx, bson.M{"instance_id": instanceID, "group": group},
|
||||
options.Find().SetSort(bson.D{{Key: "key", Value: 1}}))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -96,8 +96,8 @@ func GetSecretGroup(orgID, group string) ([]models.Secret, error) {
|
||||
return docs, nil
|
||||
}
|
||||
|
||||
func GetSecretGroupDecrypted(orgID, group string) (map[string]string, error) {
|
||||
docs, err := GetSecretGroup(orgID, group)
|
||||
func GetSecretGroupDecrypted(instanceID, group string) (map[string]string, error) {
|
||||
docs, err := GetSecretGroup(instanceID, group)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -112,12 +112,12 @@ func GetSecretGroupDecrypted(orgID, group string) (map[string]string, error) {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func RevealSecret(orgID, group, key string) (string, error) {
|
||||
func RevealSecret(instanceID, group, key string) (string, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var doc models.Secret
|
||||
err := db.Col("secrets").FindOne(ctx, bson.M{"org_id": orgID, "group": group, "key": key}).Decode(&doc)
|
||||
err := db.Col("secrets").FindOne(ctx, bson.M{"instance_id": instanceID, "group": group, "key": key}).Decode(&doc)
|
||||
if err == mongo.ErrNoDocuments {
|
||||
return "", fmt.Errorf("secret not found")
|
||||
}
|
||||
@@ -127,7 +127,7 @@ func RevealSecret(orgID, group, key string) (string, error) {
|
||||
return decryptString(doc.EncryptedValue)
|
||||
}
|
||||
|
||||
func UpsertSecrets(orgID, group string, values map[string]string) error {
|
||||
func UpsertSecrets(instanceID, group string, values map[string]string) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
@@ -137,9 +137,9 @@ func UpsertSecrets(orgID, group string, values map[string]string) error {
|
||||
return fmt.Errorf("encrypt %s: %w", key, err)
|
||||
}
|
||||
_, err = db.Col("secrets").UpdateOne(ctx,
|
||||
bson.M{"org_id": orgID, "group": group, "key": key},
|
||||
bson.M{"instance_id": instanceID, "group": group, "key": key},
|
||||
bson.M{"$set": bson.M{
|
||||
"org_id": orgID,
|
||||
"instance_id": instanceID,
|
||||
"encrypted_value": encrypted,
|
||||
"updated_at": time.Now(),
|
||||
}},
|
||||
@@ -152,7 +152,6 @@ func UpsertSecrets(orgID, group string, values map[string]string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
func SortedKeys(m map[string]string) []string {
|
||||
keys := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
@@ -162,20 +161,18 @@ func SortedKeys(m map[string]string) []string {
|
||||
return keys
|
||||
}
|
||||
|
||||
|
||||
func DeleteSecret(orgID, group, key string) error {
|
||||
func DeleteSecret(instanceID, group, key string) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
_, err := db.Col("secrets").DeleteOne(ctx, bson.M{"org_id": orgID, "group": group, "key": key})
|
||||
_, err := db.Col("secrets").DeleteOne(ctx, bson.M{"instance_id": instanceID, "group": group, "key": key})
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
func DeleteSecretGroup(orgID, group string) error {
|
||||
func DeleteSecretGroup(instanceID, group string) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
_, err := db.Col("secrets").DeleteMany(ctx, bson.M{"org_id": orgID, "group": group})
|
||||
_, err := db.Col("secrets").DeleteMany(ctx, bson.M{"instance_id": instanceID, "group": group})
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -30,14 +30,14 @@ func HashToken(token string) string {
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func CreateServer(orgID string) (*models.Server, string, error) {
|
||||
func CreateServer(instanceID string) (*models.Server, string, error) {
|
||||
token, err := generateToken(32)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
expires := time.Now().Add(time.Hour)
|
||||
s := &models.Server{
|
||||
OrgID: orgID,
|
||||
InstanceID: instanceID,
|
||||
ServerID: uuid.NewString(),
|
||||
PreRegToken: token,
|
||||
PreRegExpires: &expires,
|
||||
@@ -54,21 +54,18 @@ func CreateServer(orgID string) (*models.Server, string, error) {
|
||||
return s, token, nil
|
||||
}
|
||||
|
||||
|
||||
func GetServer(orgID, serverID string) (*models.Server, error) {
|
||||
func GetServer(instanceID, serverID string) (*models.Server, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var s models.Server
|
||||
err := db.Col("servers").FindOne(ctx, bson.M{"server_id": serverID, "org_id": orgID}).Decode(&s)
|
||||
err := db.Col("servers").FindOne(ctx, bson.M{"server_id": serverID, "instance_id": instanceID}).Decode(&s)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &s, nil
|
||||
}
|
||||
|
||||
|
||||
|
||||
func getServerByID(serverID string) (*models.Server, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
@@ -96,9 +93,6 @@ func GetServerByPreRegToken(token string) (*models.Server, error) {
|
||||
return &s, nil
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
func OSTypeFromInfo(osInfo string) string {
|
||||
if strings.HasPrefix(strings.ToLower(osInfo), "windows") {
|
||||
return "windows"
|
||||
@@ -106,8 +100,6 @@ func OSTypeFromInfo(osInfo string) string {
|
||||
return "linux"
|
||||
}
|
||||
|
||||
|
||||
|
||||
func defaultConsoleFields(osType string) (protocols []string, sshPort, rdpPort int) {
|
||||
if osType == "windows" {
|
||||
return []string{"rdp"}, 22, 3389
|
||||
@@ -181,19 +173,13 @@ func ValidateAgentToken(serverID, agentToken string) (*models.Server, error) {
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid agent token")
|
||||
}
|
||||
|
||||
|
||||
if s.OrgID == "" {
|
||||
|
||||
if s.InstanceID == "" {
|
||||
return nil, fmt.Errorf("server %s has no org", serverID)
|
||||
}
|
||||
return &s, nil
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
func BackfillConsoleConfig(srv *models.Server) error {
|
||||
if srv == nil || len(srv.ConsoleProtocols) > 0 {
|
||||
return nil
|
||||
@@ -236,12 +222,12 @@ func UpdateServerLastSeen(serverID, agentVersion string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
func ListServers(orgID string) ([]models.Server, error) {
|
||||
func ListServers(instanceID string) ([]models.Server, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
opts := options.Find().SetSort(bson.D{{Key: "created_at", Value: -1}})
|
||||
cursor, err := db.Col("servers").Find(ctx, bson.M{"org_id": orgID}, opts)
|
||||
cursor, err := db.Col("servers").Find(ctx, bson.M{"instance_id": instanceID}, opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -254,16 +240,16 @@ func ListServers(orgID string) ([]models.Server, error) {
|
||||
return servers, nil
|
||||
}
|
||||
|
||||
func DeleteServer(orgID, serverID string) error {
|
||||
func DeleteServer(instanceID, serverID string) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
_, err := db.Col("servers").DeleteOne(ctx, bson.M{"server_id": serverID, "org_id": orgID})
|
||||
_, err := db.Col("servers").DeleteOne(ctx, bson.M{"server_id": serverID, "instance_id": instanceID})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = db.Col("assignments").DeleteMany(ctx, bson.M{"server_id": serverID, "org_id": orgID})
|
||||
|
||||
_, err = db.Col("assignments").DeleteMany(ctx, bson.M{"server_id": serverID, "instance_id": instanceID})
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -283,42 +269,31 @@ func StoreAvailableUpdates(serverID string, pkgs []models.PackageUpdate) error {
|
||||
}
|
||||
|
||||
func MarkOfflineServers() error {
|
||||
orgIDs, err := ListOrgIDs()
|
||||
instanceIDs, err := ListInstanceIDs()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
for _, orgID := range orgIDs {
|
||||
if err := markOfflineForFilter(bson.M{"org_id": orgID}, orgID); err != nil {
|
||||
log.Printf("offline sweep failed for org %s: %v", orgID, err)
|
||||
for _, instanceID := range instanceIDs {
|
||||
if err := markOfflineForFilter(bson.M{"instance_id": instanceID}, instanceID); err != nil {
|
||||
log.Printf("offline sweep failed for org %s: %v", instanceID, err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
if err := markOfflineForFilter(bson.M{"org_id": bson.M{"$nin": orgIDs}}, ""); err != nil {
|
||||
if err := markOfflineForFilter(bson.M{"instance_id": bson.M{"$nin": instanceIDs}}, ""); err != nil {
|
||||
log.Printf("offline sweep failed for orphaned servers: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
func markOfflineForFilter(scope bson.M, orgID string) error {
|
||||
func markOfflineForFilter(scope bson.M, instanceID string) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var settings *models.Settings
|
||||
thresholdMinutes := 5
|
||||
if orgID != "" {
|
||||
settings, _ = GetSettings(orgID)
|
||||
if instanceID != "" {
|
||||
settings, _ = GetSettings(instanceID)
|
||||
if settings != nil && settings.Alerts.OfflineThresholdMinutes > 0 {
|
||||
thresholdMinutes = settings.Alerts.OfflineThresholdMinutes
|
||||
}
|
||||
@@ -333,7 +308,6 @@ func markOfflineForFilter(scope bson.M, orgID string) error {
|
||||
filter[k] = v
|
||||
}
|
||||
|
||||
|
||||
cursor, err := db.Col("servers").Find(ctx, filter)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -349,7 +323,7 @@ func markOfflineForFilter(scope bson.M, orgID string) error {
|
||||
}
|
||||
|
||||
for _, s := range goingOffline {
|
||||
LogEvent(s.OrgID, "server.offline", "system", s.ServerID, "", fmt.Sprintf("%s (%s) went offline", s.Hostname, s.IPAddress))
|
||||
LogEvent(s.InstanceID, "server.offline", "system", s.ServerID, "", fmt.Sprintf("%s (%s) went offline", s.Hostname, s.IPAddress))
|
||||
if settings != nil && settings.Alerts.Enabled && settings.Alerts.WebhookURL != "" {
|
||||
go SendOfflineWebhook(settings.Alerts.WebhookURL, s.Hostname, s.ServerID, s.IPAddress)
|
||||
}
|
||||
|
||||
@@ -34,9 +34,6 @@ var defaultSettings = models.Settings{
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
func EnsureSettingsIndexes() error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
@@ -46,16 +43,12 @@ func EnsureSettingsIndexes() error {
|
||||
}
|
||||
|
||||
if _, err := db.Col("settings").Indexes().CreateOne(ctx, mongo.IndexModel{
|
||||
Keys: bson.D{{Key: "org_id", Value: 1}},
|
||||
Keys: bson.D{{Key: "instance_id", Value: 1}},
|
||||
Options: options.Index().SetUnique(true),
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
_, err := db.Col("settings").Indexes().CreateOne(ctx, mongo.IndexModel{
|
||||
Keys: bson.D{{Key: "secrets.read_token_hash", Value: 1}},
|
||||
Options: options.Index().SetUnique(true).SetName("settings_read_token_hash_unique").
|
||||
@@ -66,15 +59,15 @@ func EnsureSettingsIndexes() error {
|
||||
return err
|
||||
}
|
||||
|
||||
func GetSettings(orgID string) (*models.Settings, error) {
|
||||
func GetSettings(instanceID string) (*models.Settings, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var s models.Settings
|
||||
err := db.Col("settings").FindOne(ctx, bson.M{"org_id": orgID}).Decode(&s)
|
||||
err := db.Col("settings").FindOne(ctx, bson.M{"instance_id": instanceID}).Decode(&s)
|
||||
if err == mongo.ErrNoDocuments {
|
||||
cp := defaultSettings
|
||||
cp.OrgID = orgID
|
||||
cp.InstanceID = instanceID
|
||||
return &cp, nil
|
||||
}
|
||||
if err != nil {
|
||||
@@ -89,9 +82,7 @@ func hashToken(token string) string {
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
|
||||
|
||||
func RotateSecretsReadToken(orgID string) (string, error) {
|
||||
func RotateSecretsReadToken(instanceID string) (string, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
@@ -102,13 +93,13 @@ func RotateSecretsReadToken(orgID string) (string, error) {
|
||||
token := hex.EncodeToString(raw)
|
||||
|
||||
_, err := db.Col("settings").UpdateOne(ctx,
|
||||
bson.M{"org_id": orgID},
|
||||
bson.M{"instance_id": instanceID},
|
||||
bson.M{
|
||||
"$set": bson.M{
|
||||
"secrets.read_token_hash": hashToken(token),
|
||||
"secrets.rotated_at": time.Now(),
|
||||
},
|
||||
"$setOnInsert": bson.M{"org_id": orgID},
|
||||
"$setOnInsert": bson.M{"instance_id": instanceID},
|
||||
},
|
||||
options.UpdateOne().SetUpsert(true),
|
||||
)
|
||||
@@ -118,9 +109,6 @@ func RotateSecretsReadToken(orgID string) (string, error) {
|
||||
return token, nil
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
func ResolveSecretsReadToken(token string) (string, bool) {
|
||||
if token == "" {
|
||||
return "", false
|
||||
@@ -130,7 +118,7 @@ func ResolveSecretsReadToken(token string) (string, bool) {
|
||||
|
||||
var s models.Settings
|
||||
err := db.Col("settings").FindOne(ctx, bson.M{"secrets.read_token_hash": hashToken(token)}).Decode(&s)
|
||||
if err != nil || s.Secrets.ReadTokenHash == "" || s.OrgID == "" {
|
||||
if err != nil || s.Secrets.ReadTokenHash == "" || s.InstanceID == "" {
|
||||
return "", false
|
||||
}
|
||||
expected, err := hex.DecodeString(s.Secrets.ReadTokenHash)
|
||||
@@ -141,10 +129,10 @@ func ResolveSecretsReadToken(token string) (string, bool) {
|
||||
if subtle.ConstantTimeCompare(expected, got[:]) != 1 {
|
||||
return "", false
|
||||
}
|
||||
return s.OrgID, true
|
||||
return s.InstanceID, true
|
||||
}
|
||||
|
||||
func SaveSettings(orgID string, alerts models.AlertSettings, email models.EmailSettings, retentionDays *int) error {
|
||||
func SaveSettings(instanceID string, alerts models.AlertSettings, email models.EmailSettings, retentionDays *int) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
@@ -160,17 +148,15 @@ func SaveSettings(orgID string, alerts models.AlertSettings, email models.EmailS
|
||||
set["workflow_log_retention_days"] = *retentionDays
|
||||
}
|
||||
_, err := db.Col("settings").UpdateOne(ctx,
|
||||
bson.M{"org_id": orgID},
|
||||
bson.M{"$set": set, "$setOnInsert": bson.M{"org_id": orgID}},
|
||||
bson.M{"instance_id": instanceID},
|
||||
bson.M{"$set": set, "$setOnInsert": bson.M{"instance_id": instanceID}},
|
||||
options.UpdateOne().SetUpsert(true),
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
|
||||
func GetWorkflowLogRetentionDays(orgID string) (int, error) {
|
||||
s, err := GetSettings(orgID)
|
||||
func GetWorkflowLogRetentionDays(instanceID string) (int, error) {
|
||||
s, err := GetSettings(instanceID)
|
||||
if err != nil {
|
||||
return 30, err
|
||||
}
|
||||
@@ -241,7 +227,6 @@ func SendOfflineEmail(cfg models.EmailSettings, hostname, serverID, ipAddress st
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func sendMailTLS(addr, host string, auth smtp.Auth, from string, to []string, msg []byte) error {
|
||||
conn, err := tls.Dial("tcp", addr, &tls.Config{ServerName: host})
|
||||
if err != nil {
|
||||
@@ -278,4 +263,3 @@ func sendMailTLS(addr, host string, auth smtp.Auth, from string, to []string, ms
|
||||
}
|
||||
return c.Quit()
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
|
||||
const StepDocKind = "vantage.step/v1"
|
||||
|
||||
|
||||
type StepDoc struct {
|
||||
Kind string `json:"kind"`
|
||||
Name string `json:"name"`
|
||||
@@ -21,7 +20,6 @@ type StepDoc struct {
|
||||
SecretRefs []string `json:"secret_refs"`
|
||||
}
|
||||
|
||||
|
||||
func ExportStepDoc(s models.WorkflowStep) StepDoc {
|
||||
return StepDoc{
|
||||
Kind: StepDocKind,
|
||||
@@ -35,8 +33,6 @@ func ExportStepDoc(s models.WorkflowStep) StepDoc {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
func ParseStepDoc(b []byte) (models.WorkflowStep, error) {
|
||||
var d StepDoc
|
||||
if err := json.Unmarshal(b, &d); err != nil {
|
||||
@@ -65,20 +61,18 @@ func ParseStepDoc(b []byte) (models.WorkflowStep, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
func ImportStepToLibrary(orgID string, b []byte) (*models.WorkflowStep, error) {
|
||||
func ImportStepToLibrary(instanceID string, b []byte) (*models.WorkflowStep, error) {
|
||||
s, err := ParseStepDoc(b)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return CreateStep(orgID, s)
|
||||
return CreateStep(instanceID, s)
|
||||
}
|
||||
|
||||
|
||||
func ExportStep(orgID, stepID string) ([]byte, error) {
|
||||
func ExportStep(instanceID, stepID string) ([]byte, error) {
|
||||
ctx, cancel := wfCtx()
|
||||
defer cancel()
|
||||
s, err := getStep(ctx, orgID, stepID)
|
||||
s, err := getStep(ctx, instanceID, stepID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -14,7 +14,6 @@ import (
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
)
|
||||
|
||||
|
||||
func WorkflowLogDir() string {
|
||||
dir := os.Getenv("VANTAGE_WORKFLOW_LOG_DIR")
|
||||
if dir == "" {
|
||||
@@ -24,19 +23,14 @@ func WorkflowLogDir() string {
|
||||
return dir
|
||||
}
|
||||
|
||||
|
||||
func ServerRunLogPath(runID, serverID string) string {
|
||||
return filepath.Join(WorkflowLogDir(), runID, serverID+".log")
|
||||
}
|
||||
|
||||
|
||||
|
||||
func logTS() string {
|
||||
return time.Now().UTC().Format("2006-01-02T15:04:05.000") + "Z"
|
||||
}
|
||||
|
||||
|
||||
|
||||
func AppendMarker(runID, serverID, text string) (int64, error) {
|
||||
path := ServerRunLogPath(runID, serverID)
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
|
||||
@@ -47,19 +41,17 @@ func AppendMarker(runID, serverID, text string) (int64, error) {
|
||||
return 0, err
|
||||
}
|
||||
defer f.Close()
|
||||
off, _ := f.Seek(0, 2)
|
||||
off, _ := f.Seek(0, 2)
|
||||
if _, err := f.WriteString("[" + logTS() + "] " + text + "\n"); err != nil {
|
||||
return off, err
|
||||
}
|
||||
return off, nil
|
||||
}
|
||||
|
||||
|
||||
|
||||
type stepLogWriter struct {
|
||||
mu sync.Mutex
|
||||
f *os.File
|
||||
carry []byte
|
||||
carry []byte
|
||||
secrets []string
|
||||
}
|
||||
|
||||
@@ -70,7 +62,6 @@ type stepLogRegistry struct {
|
||||
|
||||
var StepLogs = &stepLogRegistry{writers: make(map[string]*stepLogWriter)}
|
||||
|
||||
|
||||
func (r *stepLogRegistry) Open(commandID, path string, secrets []string) error {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
|
||||
return err
|
||||
@@ -92,10 +83,6 @@ func (r *stepLogRegistry) get(commandID string) *stepLogWriter {
|
||||
return r.writers[commandID]
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
func (r *stepLogRegistry) Append(commandID string, data []byte) {
|
||||
w := r.get(commandID)
|
||||
if w == nil {
|
||||
@@ -115,7 +102,6 @@ func (r *stepLogRegistry) Append(commandID string, data []byte) {
|
||||
w.carry = append([]byte{}, buf...)
|
||||
}
|
||||
|
||||
|
||||
func (w *stepLogWriter) writeLine(line []byte) {
|
||||
masked := maskBytes(line, w.secrets)
|
||||
_, _ = w.f.WriteString("[" + logTS() + "] ")
|
||||
@@ -123,7 +109,6 @@ func (w *stepLogWriter) writeLine(line []byte) {
|
||||
_, _ = w.f.WriteString("\n")
|
||||
}
|
||||
|
||||
|
||||
func (r *stepLogRegistry) Close(commandID string) {
|
||||
r.mu.Lock()
|
||||
w := r.writers[commandID]
|
||||
@@ -152,9 +137,6 @@ func maskBytes(b []byte, secrets []string) []byte {
|
||||
return []byte(s)
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
func StartLogSweeper() {
|
||||
go func() {
|
||||
sweepLogs()
|
||||
@@ -166,10 +148,6 @@ func StartLogSweeper() {
|
||||
}()
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
func sweepLogs() {
|
||||
base := WorkflowLogDir()
|
||||
entries, err := os.ReadDir(base)
|
||||
@@ -186,30 +164,28 @@ func sweepLogs() {
|
||||
runID := e.Name()
|
||||
dir := filepath.Join(base, runID)
|
||||
|
||||
orgID, finishedAt, found, err := runRetentionInfo(runID)
|
||||
instanceID, finishedAt, found, err := runRetentionInfo(runID)
|
||||
if err != nil {
|
||||
|
||||
|
||||
|
||||
|
||||
log.Printf("log sweep: retention lookup failed for run %s: %v", runID, err)
|
||||
continue
|
||||
}
|
||||
if found && finishedAt == nil {
|
||||
continue
|
||||
continue
|
||||
}
|
||||
|
||||
days, ok := cache[orgID]
|
||||
days, ok := cache[instanceID]
|
||||
if !ok {
|
||||
days = defaultRetentionDays
|
||||
if orgID != "" {
|
||||
if v, err := GetWorkflowLogRetentionDays(orgID); err == nil {
|
||||
if instanceID != "" {
|
||||
if v, err := GetWorkflowLogRetentionDays(instanceID); err == nil {
|
||||
days = v
|
||||
}
|
||||
}
|
||||
cache[orgID] = days
|
||||
cache[instanceID] = days
|
||||
}
|
||||
if days <= 0 {
|
||||
continue
|
||||
continue
|
||||
}
|
||||
cutoff := now.AddDate(0, 0, -days)
|
||||
|
||||
@@ -219,7 +195,7 @@ func sweepLogs() {
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
|
||||
if fi, e := os.Stat(dir); e == nil && fi.ModTime().Before(cutoff) {
|
||||
_ = os.RemoveAll(dir)
|
||||
}
|
||||
@@ -228,14 +204,11 @@ func sweepLogs() {
|
||||
|
||||
const defaultRetentionDays = 30
|
||||
|
||||
|
||||
|
||||
|
||||
func runRetentionInfo(runID string) (string, *time.Time, bool, error) {
|
||||
ctx, cancel := wfCtx()
|
||||
defer cancel()
|
||||
var run struct {
|
||||
OrgID string `bson:"org_id"`
|
||||
InstanceID string `bson:"instance_id"`
|
||||
FinishedAt *time.Time `bson:"finished_at"`
|
||||
}
|
||||
err := db.Col("workflow_runs").FindOne(ctx, bson.M{"run_id": runID}).Decode(&run)
|
||||
@@ -245,5 +218,5 @@ func runRetentionInfo(runID string) (string, *time.Time, bool, error) {
|
||||
if err != nil {
|
||||
return "", nil, false, err
|
||||
}
|
||||
return run.OrgID, run.FinishedAt, true, nil
|
||||
return run.InstanceID, run.FinishedAt, true, nil
|
||||
}
|
||||
|
||||
@@ -11,12 +11,8 @@ type stepResultRegistry struct {
|
||||
pending map[string]chan *pb.StepResult
|
||||
}
|
||||
|
||||
|
||||
|
||||
var StepResults = &stepResultRegistry{pending: make(map[string]chan *pb.StepResult)}
|
||||
|
||||
|
||||
|
||||
func (r *stepResultRegistry) Await(commandID string) <-chan *pb.StepResult {
|
||||
ch := make(chan *pb.StepResult, 1)
|
||||
r.mu.Lock()
|
||||
@@ -25,14 +21,12 @@ func (r *stepResultRegistry) Await(commandID string) <-chan *pb.StepResult {
|
||||
return ch
|
||||
}
|
||||
|
||||
|
||||
func (r *stepResultRegistry) Cancel(commandID string) {
|
||||
r.mu.Lock()
|
||||
delete(r.pending, commandID)
|
||||
r.mu.Unlock()
|
||||
}
|
||||
|
||||
|
||||
func (r *stepResultRegistry) Deliver(res *pb.StepResult) {
|
||||
if res == nil {
|
||||
return
|
||||
|
||||
@@ -5,12 +5,8 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
|
||||
var keyAssign = regexp.MustCompile(`([A-Za-z_][A-Za-z0-9_]*)=`)
|
||||
|
||||
|
||||
|
||||
|
||||
func DeriveOutputs(script string) []string {
|
||||
out := []string{}
|
||||
seen := map[string]bool{}
|
||||
@@ -20,7 +16,7 @@ func DeriveOutputs(script string) []string {
|
||||
}
|
||||
for _, m := range keyAssign.FindAllStringSubmatch(line, -1) {
|
||||
key := m[1]
|
||||
|
||||
|
||||
if key == "WORKFLOW_ENV" || key == "env" {
|
||||
continue
|
||||
}
|
||||
@@ -34,11 +30,5 @@ func DeriveOutputs(script string) []string {
|
||||
return out
|
||||
}
|
||||
|
||||
var slugStrip = regexp.MustCompile(`[^a-z0-9]+`)
|
||||
|
||||
|
||||
func Slugify(name string) string {
|
||||
s := strings.ToLower(name)
|
||||
s = slugStrip.ReplaceAllString(s, "-")
|
||||
return strings.Trim(s, "-")
|
||||
}
|
||||
// Slugify lived here and was mirrored by hand in sitesvc. It now has a single
|
||||
// definition in shared/provision, which both services import.
|
||||
|
||||
@@ -13,17 +13,15 @@ func BuildAuthorizedKeys(serverID string) ([]string, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
|
||||
|
||||
srv, err := getServerByID(serverID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cursor, err := db.Col("assignments").Find(ctx, bson.M{
|
||||
"org_id": srv.OrgID,
|
||||
"server_id": serverID,
|
||||
"revoked_at": nil,
|
||||
"instance_id": srv.InstanceID,
|
||||
"server_id": serverID,
|
||||
"revoked_at": nil,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -38,7 +36,7 @@ func BuildAuthorizedKeys(serverID string) ([]string, error) {
|
||||
var lines []string
|
||||
for _, a := range assignments {
|
||||
var key models.Key
|
||||
err := db.Col("keys").FindOne(ctx, bson.M{"key_id": a.KeyID, "org_id": srv.OrgID}).Decode(&key)
|
||||
err := db.Col("keys").FindOne(ctx, bson.M{"key_id": a.KeyID, "instance_id": srv.InstanceID}).Decode(&key)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -7,88 +7,59 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/mrhid6/vantage/server/internal/db"
|
||||
"github.com/mrhid6/vantage/server/internal/models"
|
||||
"github.com/mrhid6/vantage/shared/provision"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
|
||||
|
||||
var ErrLastOwner = errors.New("this is the organization's last owner promote another member to owner first")
|
||||
|
||||
|
||||
|
||||
|
||||
func CountUsers() (int64, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
return db.Col("users").CountDocuments(ctx, bson.M{})
|
||||
}
|
||||
|
||||
func CountOrgUsers(orgID string) (int64, error) {
|
||||
func CountInstanceUsers(instanceID string) (int64, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
return db.Col("users").CountDocuments(ctx, bson.M{"org_id": orgID})
|
||||
return db.Col("users").CountDocuments(ctx, bson.M{"instance_id": instanceID})
|
||||
}
|
||||
|
||||
|
||||
|
||||
func countOtherOwners(orgID, exceptUserID string) (int64, error) {
|
||||
func countOtherOwners(instanceID, exceptUserID string) (int64, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
return db.Col("users").CountDocuments(ctx, bson.M{
|
||||
"org_id": orgID,
|
||||
"role": models.RoleOwner,
|
||||
"user_id": bson.M{"$ne": exceptUserID},
|
||||
"instance_id": instanceID,
|
||||
"role": models.RoleOwner,
|
||||
"user_id": bson.M{"$ne": exceptUserID},
|
||||
})
|
||||
}
|
||||
|
||||
func GetUserInOrg(orgID, userID string) (*models.User, error) {
|
||||
func GetUserInInstance(instanceID, userID string) (*models.User, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
var u models.User
|
||||
err := db.Col("users").FindOne(ctx, bson.M{"user_id": userID, "org_id": orgID}).Decode(&u)
|
||||
err := db.Col("users").FindOne(ctx, bson.M{"user_id": userID, "instance_id": instanceID}).Decode(&u)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &u, nil
|
||||
}
|
||||
|
||||
func CreateUser(orgID, email, password, role, authSource string) (*models.User, error) {
|
||||
email = strings.ToLower(strings.TrimSpace(email))
|
||||
if email == "" {
|
||||
return nil, fmt.Errorf("email required")
|
||||
}
|
||||
if !models.ValidRole(role) {
|
||||
return nil, fmt.Errorf("invalid role %q", role)
|
||||
}
|
||||
u := &models.User{
|
||||
UserID: uuid.NewString(),
|
||||
OrgID: orgID,
|
||||
Email: email,
|
||||
Role: role,
|
||||
AuthSource: authSource,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
if password != "" {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), 12)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
u.PasswordHash = string(hash)
|
||||
}
|
||||
func CreateUser(instanceID, email, password, role, authSource string) (*models.User, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
if _, err := db.Col("users").InsertOne(ctx, u); err != nil {
|
||||
if mongo.IsDuplicateKeyError(err) {
|
||||
return nil, fmt.Errorf("email already registered")
|
||||
}
|
||||
return nil, err
|
||||
|
||||
u, err := provision.CreateUser(ctx, db.Database, instanceID, email, password, role, authSource)
|
||||
if errors.Is(err, provision.ErrEmailTaken) {
|
||||
// Preserve the exact error string the API returned before this call
|
||||
// was delegated to the shared module.
|
||||
return nil, fmt.Errorf("email already registered")
|
||||
}
|
||||
return u, nil
|
||||
return u, err
|
||||
}
|
||||
|
||||
func GetUserByEmail(email string) (*models.User, error) {
|
||||
@@ -119,10 +90,10 @@ func TouchLastLogin(userID string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
func ListUsers(orgID string) ([]models.User, error) {
|
||||
func ListUsers(instanceID string) ([]models.User, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
cursor, err := db.Col("users").Find(ctx, bson.M{"org_id": orgID})
|
||||
cursor, err := db.Col("users").Find(ctx, bson.M{"instance_id": instanceID})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -134,17 +105,17 @@ func ListUsers(orgID string) ([]models.User, error) {
|
||||
return users, nil
|
||||
}
|
||||
|
||||
func UpdateUserRole(orgID, userID, role string) error {
|
||||
func UpdateUserRole(instanceID, userID, role string) error {
|
||||
if !models.ValidRole(role) {
|
||||
return fmt.Errorf("invalid role %q", role)
|
||||
}
|
||||
target, err := GetUserInOrg(orgID, userID)
|
||||
target, err := GetUserInInstance(instanceID, userID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("user not found")
|
||||
}
|
||||
|
||||
|
||||
if target.Role == models.RoleOwner && role != models.RoleOwner {
|
||||
others, err := countOtherOwners(orgID, userID)
|
||||
others, err := countOtherOwners(instanceID, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -156,18 +127,18 @@ func UpdateUserRole(orgID, userID, role string) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
_, err = db.Col("users").UpdateOne(ctx,
|
||||
bson.M{"user_id": userID, "org_id": orgID},
|
||||
bson.M{"user_id": userID, "instance_id": instanceID},
|
||||
bson.M{"$set": bson.M{"role": role}})
|
||||
return err
|
||||
}
|
||||
|
||||
func DeleteUser(orgID, userID string) error {
|
||||
target, err := GetUserInOrg(orgID, userID)
|
||||
func DeleteUser(instanceID, userID string) error {
|
||||
target, err := GetUserInInstance(instanceID, userID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("user not found")
|
||||
}
|
||||
if target.Role == models.RoleOwner {
|
||||
others, err := countOtherOwners(orgID, userID)
|
||||
others, err := countOtherOwners(instanceID, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -178,6 +149,6 @@ func DeleteUser(orgID, userID string) error {
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
_, err = db.Col("users").DeleteOne(ctx, bson.M{"user_id": userID, "org_id": orgID})
|
||||
_, err = db.Col("users").DeleteOne(ctx, bson.M{"user_id": userID, "instance_id": instanceID})
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"github.com/mrhid6/vantage/server/internal/models"
|
||||
)
|
||||
|
||||
|
||||
func ValidateWorkflow(w models.Workflow) error {
|
||||
for i, ref := range w.Steps {
|
||||
hasLib := ref.StepID != ""
|
||||
|
||||
@@ -17,8 +17,8 @@ import (
|
||||
|
||||
const stepDispatchGrace = 15 * time.Second
|
||||
|
||||
func TriggerWorkflow(orgID, workflowID, actor string) (string, error) {
|
||||
wf, err := GetWorkflow(orgID, workflowID)
|
||||
func TriggerWorkflow(instanceID, workflowID, actor string) (string, error) {
|
||||
wf, err := GetWorkflow(instanceID, workflowID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -29,24 +29,24 @@ func TriggerWorkflow(orgID, workflowID, actor string) (string, error) {
|
||||
return "", fmt.Errorf("workflow has no steps")
|
||||
}
|
||||
|
||||
if err := validateTargetServers(orgID, wf.TargetServerIDs); err != nil {
|
||||
if err := validateTargetServers(instanceID, wf.TargetServerIDs); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
ctx, cancel := wfCtx()
|
||||
running := db.Col("workflow_runs").FindOne(ctx, bson.M{"org_id": orgID, "workflow_id": workflowID, "status": "running"})
|
||||
running := db.Col("workflow_runs").FindOne(ctx, bson.M{"instance_id": instanceID, "workflow_id": workflowID, "status": "running"})
|
||||
cancel()
|
||||
if running.Err() == nil {
|
||||
return "", fmt.Errorf("workflow already has a run in progress")
|
||||
}
|
||||
|
||||
resolved, err := resolveSteps(orgID, wf)
|
||||
resolved, err := resolveSteps(instanceID, wf)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
run := models.WorkflowRun{
|
||||
OrgID: orgID,
|
||||
InstanceID: instanceID,
|
||||
RunID: uuid.New().String(),
|
||||
WorkflowID: workflowID,
|
||||
Name: wf.Name,
|
||||
@@ -78,7 +78,7 @@ func TriggerWorkflow(orgID, workflowID, actor string) (string, error) {
|
||||
return run.RunID, nil
|
||||
}
|
||||
|
||||
func resolveSteps(orgID string, wf *models.Workflow) ([]models.ResolvedStep, error) {
|
||||
func resolveSteps(instanceID string, wf *models.Workflow) ([]models.ResolvedStep, error) {
|
||||
ctx, cancel := wfCtx()
|
||||
defer cancel()
|
||||
out := make([]models.ResolvedStep, 0, len(wf.Steps))
|
||||
@@ -87,7 +87,7 @@ func resolveSteps(orgID string, wf *models.Workflow) ([]models.ResolvedStep, err
|
||||
out = append(out, resolveInlineStep(ref))
|
||||
continue
|
||||
}
|
||||
lib, err := getStep(ctx, orgID, ref.StepID)
|
||||
lib, err := getStep(ctx, instanceID, ref.StepID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -163,7 +163,7 @@ func executeRun(runID string) {
|
||||
done := make(chan int, len(run.ServerRuns))
|
||||
for i := range run.ServerRuns {
|
||||
go func(idx int) {
|
||||
runServer(run.OrgID, runID, idx, run.Steps, run.ServerRuns[idx].ServerID)
|
||||
runServer(run.InstanceID, runID, idx, run.Steps, run.ServerRuns[idx].ServerID)
|
||||
done <- idx
|
||||
}(i)
|
||||
}
|
||||
@@ -185,7 +185,7 @@ func executeRun(runID string) {
|
||||
bson.M{"$set": bson.M{"status": status, "finished_at": now}})
|
||||
}
|
||||
|
||||
func runServer(orgID, runID string, srvIdx int, steps []models.ResolvedStep, serverID string) {
|
||||
func runServer(instanceID, runID string, srvIdx int, steps []models.ResolvedStep, serverID string) {
|
||||
now := time.Now()
|
||||
setServerRun(runID, srvIdx, bson.M{"server_runs.$.status": "running", "server_runs.$.started_at": now})
|
||||
|
||||
@@ -212,7 +212,7 @@ func runServer(orgID, runID string, srvIdx int, steps []models.ResolvedStep, ser
|
||||
maxAttempts = step.MaxRetries + 1
|
||||
}
|
||||
|
||||
secretVals := resolveSecrets(orgID, step.SecretRefs)
|
||||
secretVals := resolveSecrets(instanceID, step.SecretRefs)
|
||||
for k, v := range secretVals {
|
||||
allSecrets[k] = v
|
||||
}
|
||||
@@ -347,7 +347,7 @@ func expandVars(v string, lookup map[string]string) string {
|
||||
})
|
||||
}
|
||||
|
||||
func resolveSecrets(orgID string, refs []string) map[string]string {
|
||||
func resolveSecrets(instanceID string, refs []string) map[string]string {
|
||||
out := map[string]string{}
|
||||
for _, ref := range refs {
|
||||
|
||||
@@ -355,7 +355,7 @@ func resolveSecrets(orgID string, refs []string) map[string]string {
|
||||
if len(parts) != 2 {
|
||||
continue
|
||||
}
|
||||
if v, err := RevealSecret(orgID, parts[0], parts[1]); err == nil {
|
||||
if v, err := RevealSecret(instanceID, parts[0], parts[1]); err == nil {
|
||||
out[parts[1]] = v
|
||||
}
|
||||
}
|
||||
@@ -453,21 +453,21 @@ func getRunByID(runID string) (*models.WorkflowRun, error) {
|
||||
return &r, err
|
||||
}
|
||||
|
||||
func GetRun(orgID, runID string) (*models.WorkflowRun, error) {
|
||||
func GetRun(instanceID, runID string) (*models.WorkflowRun, error) {
|
||||
ctx, cancel := wfCtx()
|
||||
defer cancel()
|
||||
var r models.WorkflowRun
|
||||
err := db.Col("workflow_runs").FindOne(ctx, bson.M{"run_id": runID, "org_id": orgID}).Decode(&r)
|
||||
err := db.Col("workflow_runs").FindOne(ctx, bson.M{"run_id": runID, "instance_id": instanceID}).Decode(&r)
|
||||
if err == mongo.ErrNoDocuments {
|
||||
return nil, fmt.Errorf("run not found")
|
||||
}
|
||||
return &r, err
|
||||
}
|
||||
|
||||
func ListRuns(orgID, workflowID string, limit int64) ([]models.WorkflowRun, error) {
|
||||
func ListRuns(instanceID, workflowID string, limit int64) ([]models.WorkflowRun, error) {
|
||||
ctx, cancel := wfCtx()
|
||||
defer cancel()
|
||||
cur, err := db.Col("workflow_runs").Find(ctx, bson.M{"org_id": orgID, "workflow_id": workflowID},
|
||||
cur, err := db.Col("workflow_runs").Find(ctx, bson.M{"instance_id": instanceID, "workflow_id": workflowID},
|
||||
options.Find().SetSort(bson.D{{Key: "started_at", Value: -1}}).SetLimit(limit))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -480,12 +480,12 @@ func ListRuns(orgID, workflowID string, limit int64) ([]models.WorkflowRun, erro
|
||||
return runs, nil
|
||||
}
|
||||
|
||||
func CancelRun(orgID, runID string) error {
|
||||
func CancelRun(instanceID, runID string) error {
|
||||
now := time.Now()
|
||||
ctx, cancel := wfCtx()
|
||||
defer cancel()
|
||||
_, err := db.Col("workflow_runs").UpdateOne(ctx,
|
||||
bson.M{"org_id": orgID, "run_id": runID, "status": "running"},
|
||||
bson.M{"instance_id": instanceID, "run_id": runID, "status": "running"},
|
||||
bson.M{"$set": bson.M{"status": "cancelled", "finished_at": now}})
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -25,13 +25,12 @@ func EnsureWorkflowIndexes() error {
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
|
||||
if err := db.Col("workflow_steps").Indexes().DropOne(ctx, "slug_1"); err != nil && !isIndexNotFound(err) {
|
||||
return err
|
||||
}
|
||||
if _, err := db.Col("workflow_steps").Indexes().CreateOne(ctx, mongo.IndexModel{
|
||||
Keys: bson.D{{Key: "org_id", Value: 1}, {Key: "slug", Value: 1}},
|
||||
Keys: bson.D{{Key: "instance_id", Value: 1}, {Key: "slug", Value: 1}},
|
||||
Options: options.Index().SetUnique(true).
|
||||
SetPartialFilterExpression(bson.M{"source": "default"}),
|
||||
}); err != nil {
|
||||
@@ -48,12 +47,10 @@ func EnsureWorkflowIndexes() error {
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
|
||||
func ListSteps(orgID string) ([]models.WorkflowStep, error) {
|
||||
func ListSteps(instanceID string) ([]models.WorkflowStep, error) {
|
||||
ctx, cancel := wfCtx()
|
||||
defer cancel()
|
||||
cur, err := db.Col("workflow_steps").Find(ctx, bson.M{"org_id": orgID},
|
||||
cur, err := db.Col("workflow_steps").Find(ctx, bson.M{"instance_id": instanceID},
|
||||
options.Find().SetSort(bson.D{{Key: "name", Value: 1}}))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -66,12 +63,10 @@ func ListSteps(orgID string) ([]models.WorkflowStep, error) {
|
||||
return steps, nil
|
||||
}
|
||||
|
||||
|
||||
|
||||
func StepUsageCounts(orgID string) (map[string]int, error) {
|
||||
func StepUsageCounts(instanceID string) (map[string]int, error) {
|
||||
ctx, cancel := wfCtx()
|
||||
defer cancel()
|
||||
cur, err := db.Col("workflows").Find(ctx, bson.M{"org_id": orgID})
|
||||
cur, err := db.Col("workflows").Find(ctx, bson.M{"instance_id": instanceID})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -94,10 +89,10 @@ func StepUsageCounts(orgID string) (map[string]int, error) {
|
||||
return counts, nil
|
||||
}
|
||||
|
||||
func CreateStep(orgID string, s models.WorkflowStep) (*models.WorkflowStep, error) {
|
||||
func CreateStep(instanceID string, s models.WorkflowStep) (*models.WorkflowStep, error) {
|
||||
ctx, cancel := wfCtx()
|
||||
defer cancel()
|
||||
s.OrgID = orgID
|
||||
s.InstanceID = instanceID
|
||||
s.StepID = uuid.New().String()
|
||||
s.CreatedAt = time.Now()
|
||||
s.UpdatedAt = s.CreatedAt
|
||||
@@ -117,10 +112,10 @@ func CreateStep(orgID string, s models.WorkflowStep) (*models.WorkflowStep, erro
|
||||
return &s, nil
|
||||
}
|
||||
|
||||
func UpdateStep(orgID, stepID string, s models.WorkflowStep) error {
|
||||
func UpdateStep(instanceID, stepID string, s models.WorkflowStep) error {
|
||||
ctx, cancel := wfCtx()
|
||||
defer cancel()
|
||||
_, err := db.Col("workflow_steps").UpdateOne(ctx, bson.M{"step_id": stepID, "org_id": orgID}, bson.M{"$set": bson.M{
|
||||
_, err := db.Col("workflow_steps").UpdateOne(ctx, bson.M{"step_id": stepID, "instance_id": instanceID}, bson.M{"$set": bson.M{
|
||||
"name": s.Name,
|
||||
"description": s.Description,
|
||||
"interpreter": s.Interpreter,
|
||||
@@ -133,14 +128,14 @@ func UpdateStep(orgID, stepID string, s models.WorkflowStep) error {
|
||||
return err
|
||||
}
|
||||
|
||||
func DeleteStep(orgID, stepID string) error {
|
||||
func DeleteStep(instanceID, stepID string) error {
|
||||
ctx, cancel := wfCtx()
|
||||
defer cancel()
|
||||
if _, err := db.Col("workflow_steps").DeleteOne(ctx, bson.M{"step_id": stepID, "org_id": orgID}); err != nil {
|
||||
if _, err := db.Col("workflow_steps").DeleteOne(ctx, bson.M{"step_id": stepID, "instance_id": instanceID}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cur, err := db.Col("workflows").Find(ctx, bson.M{"steps.step_id": stepID, "org_id": orgID})
|
||||
|
||||
cur, err := db.Col("workflows").Find(ctx, bson.M{"steps.step_id": stepID, "instance_id": instanceID})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -170,21 +165,19 @@ func DeleteStep(orgID, stepID string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func getStep(ctx context.Context, orgID, stepID string) (*models.WorkflowStep, error) {
|
||||
func getStep(ctx context.Context, instanceID, stepID string) (*models.WorkflowStep, error) {
|
||||
var s models.WorkflowStep
|
||||
err := db.Col("workflow_steps").FindOne(ctx, bson.M{"step_id": stepID, "org_id": orgID}).Decode(&s)
|
||||
err := db.Col("workflow_steps").FindOne(ctx, bson.M{"step_id": stepID, "instance_id": instanceID}).Decode(&s)
|
||||
if err == mongo.ErrNoDocuments {
|
||||
return nil, fmt.Errorf("step %s not found", stepID)
|
||||
}
|
||||
return &s, err
|
||||
}
|
||||
|
||||
|
||||
|
||||
func ListWorkflows(orgID string) ([]models.Workflow, error) {
|
||||
func ListWorkflows(instanceID string) ([]models.Workflow, error) {
|
||||
ctx, cancel := wfCtx()
|
||||
defer cancel()
|
||||
cur, err := db.Col("workflows").Find(ctx, bson.M{"org_id": orgID},
|
||||
cur, err := db.Col("workflows").Find(ctx, bson.M{"instance_id": instanceID},
|
||||
options.Find().SetSort(bson.D{{Key: "name", Value: 1}}))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -197,21 +190,21 @@ func ListWorkflows(orgID string) ([]models.Workflow, error) {
|
||||
return wfs, nil
|
||||
}
|
||||
|
||||
func GetWorkflow(orgID, id string) (*models.Workflow, error) {
|
||||
func GetWorkflow(instanceID, id string) (*models.Workflow, error) {
|
||||
ctx, cancel := wfCtx()
|
||||
defer cancel()
|
||||
var w models.Workflow
|
||||
err := db.Col("workflows").FindOne(ctx, bson.M{"workflow_id": id, "org_id": orgID}).Decode(&w)
|
||||
err := db.Col("workflows").FindOne(ctx, bson.M{"workflow_id": id, "instance_id": instanceID}).Decode(&w)
|
||||
if err == mongo.ErrNoDocuments {
|
||||
return nil, fmt.Errorf("workflow not found")
|
||||
}
|
||||
return &w, err
|
||||
}
|
||||
|
||||
func CreateWorkflow(orgID string, w models.Workflow) (*models.Workflow, error) {
|
||||
func CreateWorkflow(instanceID string, w models.Workflow) (*models.Workflow, error) {
|
||||
ctx, cancel := wfCtx()
|
||||
defer cancel()
|
||||
w.OrgID = orgID
|
||||
w.InstanceID = instanceID
|
||||
w.WorkflowID = uuid.New().String()
|
||||
w.CreatedAt = time.Now()
|
||||
w.UpdatedAt = w.CreatedAt
|
||||
@@ -224,7 +217,7 @@ func CreateWorkflow(orgID string, w models.Workflow) (*models.Workflow, error) {
|
||||
if err := ValidateWorkflow(w); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateTargetServers(orgID, w.TargetServerIDs); err != nil {
|
||||
if err := validateTargetServers(instanceID, w.TargetServerIDs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
normalizeInlineSteps(&w)
|
||||
@@ -234,17 +227,17 @@ func CreateWorkflow(orgID string, w models.Workflow) (*models.Workflow, error) {
|
||||
return &w, nil
|
||||
}
|
||||
|
||||
func UpdateWorkflow(orgID, id string, w models.Workflow) error {
|
||||
func UpdateWorkflow(instanceID, id string, w models.Workflow) error {
|
||||
ctx, cancel := wfCtx()
|
||||
defer cancel()
|
||||
if err := ValidateWorkflow(w); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateTargetServers(orgID, w.TargetServerIDs); err != nil {
|
||||
if err := validateTargetServers(instanceID, w.TargetServerIDs); err != nil {
|
||||
return err
|
||||
}
|
||||
normalizeInlineSteps(&w)
|
||||
_, err := db.Col("workflows").UpdateOne(ctx, bson.M{"workflow_id": id, "org_id": orgID}, bson.M{"$set": bson.M{
|
||||
_, err := db.Col("workflows").UpdateOne(ctx, bson.M{"workflow_id": id, "instance_id": instanceID}, bson.M{"$set": bson.M{
|
||||
"name": w.Name,
|
||||
"target_server_ids": w.TargetServerIDs,
|
||||
"steps": w.Steps,
|
||||
@@ -253,20 +246,15 @@ func UpdateWorkflow(orgID, id string, w models.Workflow) error {
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
func validateTargetServers(orgID string, serverIDs []string) error {
|
||||
func validateTargetServers(instanceID string, serverIDs []string) error {
|
||||
for _, sid := range serverIDs {
|
||||
if _, err := GetServer(orgID, sid); err != nil {
|
||||
if _, err := GetServer(instanceID, sid); err != nil {
|
||||
return fmt.Errorf("target server %s not found", sid)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
|
||||
func normalizeInlineSteps(w *models.Workflow) {
|
||||
for i := range w.Steps {
|
||||
in := w.Steps[i].Inline
|
||||
@@ -288,9 +276,9 @@ func normalizeInlineSteps(w *models.Workflow) {
|
||||
}
|
||||
}
|
||||
|
||||
func DeleteWorkflow(orgID, id string) error {
|
||||
func DeleteWorkflow(instanceID, id string) error {
|
||||
ctx, cancel := wfCtx()
|
||||
defer cancel()
|
||||
_, err := db.Col("workflows").DeleteOne(ctx, bson.M{"workflow_id": id, "org_id": orgID})
|
||||
_, err := db.Col("workflows").DeleteOne(ctx, bson.M{"workflow_id": id, "instance_id": instanceID})
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
module github.com/mrhid6/vantage/shared
|
||||
|
||||
go 1.26.4
|
||||
|
||||
require (
|
||||
github.com/google/uuid v1.6.0
|
||||
go.mongodb.org/mongo-driver/v2 v2.8.0
|
||||
golang.org/x/crypto v0.54.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/klauspost/compress v1.17.6 // indirect
|
||||
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
|
||||
github.com/xdg-go/scram v1.2.0 // indirect
|
||||
github.com/xdg-go/stringprep v1.0.4 // indirect
|
||||
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
|
||||
golang.org/x/sync v0.22.0 // indirect
|
||||
golang.org/x/text v0.40.0 // indirect
|
||||
)
|
||||
@@ -0,0 +1,48 @@
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/klauspost/compress v1.17.6 h1:60eq2E/jlfwQXtvZEeBUYADs+BwKBWURIY+Gj2eRGjI=
|
||||
github.com/klauspost/compress v1.17.6/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM=
|
||||
github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c=
|
||||
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
|
||||
github.com/xdg-go/scram v1.2.0 h1:bYKF2AEwG5rqd1BumT4gAnvwU/M9nBp2pTSxeZw7Wvs=
|
||||
github.com/xdg-go/scram v1.2.0/go.mod h1:3dlrS0iBaWKYVt2ZfA4cj48umJZ+cAEbR6/SjLA88I8=
|
||||
github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8=
|
||||
github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM=
|
||||
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM=
|
||||
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
go.mongodb.org/mongo-driver/v2 v2.8.0 h1:CxWDGQYY8QQwNjAl/aq2sfWakdnWZynnqJ9F4DhHbP8=
|
||||
go.mongodb.org/mongo-driver/v2 v2.8.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
|
||||
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
||||
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
|
||||
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
@@ -0,0 +1,39 @@
|
||||
// Package indexes declares the MongoDB indexes more than one Vantage service
|
||||
// depends on.
|
||||
package indexes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo/options"
|
||||
)
|
||||
|
||||
// EnsureCoreIndexes declares the unique indexes on users.email and orgs.slug.
|
||||
//
|
||||
// These are a security property, not an optimisation. GetUserByEmail does an
|
||||
// unscoped FindOne, so a duplicate email would let the OIDC cross-org guard
|
||||
// compare against an arbitrary user. Every caller must treat a failure here as
|
||||
// fatal.
|
||||
//
|
||||
// Creating an index that already exists with the same specification is a no-op,
|
||||
// so this is safe to call at every boot from every service.
|
||||
func EnsureCoreIndexes(ctx context.Context, db *mongo.Database) error {
|
||||
if _, err := db.Collection("users").Indexes().CreateOne(ctx, mongo.IndexModel{
|
||||
Keys: bson.D{{Key: "email", Value: 1}},
|
||||
Options: options.Index().SetUnique(true),
|
||||
}); err != nil {
|
||||
return fmt.Errorf("users.email index: %w", err)
|
||||
}
|
||||
|
||||
if _, err := db.Collection("instances").Indexes().CreateOne(ctx, mongo.IndexModel{
|
||||
Keys: bson.D{{Key: "slug", Value: 1}},
|
||||
Options: options.Index().SetUnique(true),
|
||||
}); err != nil {
|
||||
return fmt.Errorf("instances.slug index: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
// Package models holds the MongoDB documents written by more than one Vantage
|
||||
// service. Documents only the control plane touches stay in
|
||||
// server/internal/models.
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
// Instance is one deployment of Vantage: its own subdomain, users, servers,
|
||||
// keys, workflows, monitors and secrets. It is the unit a licence attaches to.
|
||||
//
|
||||
// A paying customer may hold several. That grouping is called an Account and
|
||||
// lives only in the admin control plane — this service never sees it.
|
||||
type Instance struct {
|
||||
ID bson.ObjectID `bson:"_id,omitempty" json:"_id,omitempty"`
|
||||
InstanceID string `bson:"instance_id" json:"instance_id"`
|
||||
Name string `bson:"name" json:"name"`
|
||||
Slug string `bson:"slug" json:"slug"`
|
||||
CreatedAt time.Time `bson:"created_at" json:"created_at"`
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
type AlertSettings struct {
|
||||
Enabled bool `bson:"enabled" json:"enabled"`
|
||||
WebhookURL string `bson:"webhook_url" json:"webhook_url"`
|
||||
OfflineThresholdMinutes int `bson:"offline_threshold_minutes" json:"offline_threshold_minutes"`
|
||||
}
|
||||
|
||||
type EmailSettings struct {
|
||||
Enabled bool `bson:"enabled" json:"enabled"`
|
||||
SMTPHost string `bson:"smtp_host" json:"smtp_host"`
|
||||
SMTPPort int `bson:"smtp_port" json:"smtp_port"`
|
||||
Username string `bson:"username" json:"username"`
|
||||
Password string `bson:"password" json:"password"`
|
||||
FromAddr string `bson:"from_addr" json:"from_addr"`
|
||||
ToAddrs []string `bson:"to_addrs" json:"to_addrs"`
|
||||
UseTLS bool `bson:"use_tls" json:"use_tls"`
|
||||
}
|
||||
|
||||
type SecretsSettings struct {
|
||||
ReadTokenHash string `bson:"read_token_hash,omitempty" json:"-"`
|
||||
ReadTokenSet bool `bson:"-" json:"read_token_set"`
|
||||
RotatedAt time.Time `bson:"rotated_at,omitempty" json:"rotated_at,omitempty"`
|
||||
}
|
||||
|
||||
type Settings struct {
|
||||
ID bson.ObjectID `bson:"_id,omitempty" json:"-"`
|
||||
InstanceID string `bson:"instance_id" json:"instance_id"`
|
||||
Alerts AlertSettings `bson:"alerts" json:"alerts"`
|
||||
Email EmailSettings `bson:"email" json:"email"`
|
||||
Secrets SecretsSettings `bson:"secrets" json:"secrets"`
|
||||
|
||||
WorkflowLogRetentionDays *int `bson:"workflow_log_retention_days,omitempty" json:"workflow_log_retention_days,omitempty"`
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
const (
|
||||
RoleOwner = "owner"
|
||||
RoleAdmin = "admin"
|
||||
RoleMember = "member"
|
||||
)
|
||||
|
||||
func ValidRole(role string) bool {
|
||||
switch role {
|
||||
case RoleOwner, RoleAdmin, RoleMember:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type User struct {
|
||||
ID bson.ObjectID `bson:"_id,omitempty" json:"_id,omitempty"`
|
||||
UserID string `bson:"user_id" json:"user_id"`
|
||||
InstanceID string `bson:"instance_id" json:"instance_id"`
|
||||
Email string `bson:"email" json:"email"`
|
||||
PasswordHash string `bson:"password_hash,omitempty" json:"-"`
|
||||
Role string `bson:"role" json:"role"`
|
||||
AuthSource string `bson:"auth_source" json:"auth_source"`
|
||||
CreatedAt time.Time `bson:"created_at" json:"created_at"`
|
||||
LastLogin *time.Time `bson:"last_login,omitempty" json:"last_login,omitempty"`
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package provision
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/mrhid6/vantage/shared/models"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
)
|
||||
|
||||
// ErrNameRejected wraps every reason a name cannot become an instance.
|
||||
var ErrNameRejected = errors.New("organisation name rejected")
|
||||
|
||||
const maxSlugAttempts = 50
|
||||
|
||||
// CreateInstance inserts an instance under the first free slug derived from name.
|
||||
//
|
||||
// The count-then-insert loop is racy on its own. It is safe only because
|
||||
// instances.slug carries a unique index: a lost race surfaces as a duplicate-key
|
||||
// error, which we treat as "that slug is taken" and retry. Do not remove the
|
||||
// duplicate-key branch, and do not remove the index.
|
||||
func CreateInstance(ctx context.Context, db *mongo.Database, name string) (*models.Instance, error) {
|
||||
base, err := BaseSlug(name)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %s", ErrNameRejected, err.Error())
|
||||
}
|
||||
|
||||
for attempt := 1; attempt <= maxSlugAttempts; attempt++ {
|
||||
slug := NextSlug(base, attempt)
|
||||
|
||||
n, err := db.Collection("instances").CountDocuments(ctx, bson.M{"slug": slug})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if n > 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
inst := models.Instance{
|
||||
InstanceID: uuid.NewString(),
|
||||
Name: name,
|
||||
Slug: slug,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
}
|
||||
if _, err := db.Collection("instances").InsertOne(ctx, inst); err != nil {
|
||||
if mongo.IsDuplicateKeyError(err) {
|
||||
continue // lost the race; try the next slug
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &inst, nil
|
||||
}
|
||||
return nil, fmt.Errorf("%w: could not find a free slug for %q", ErrNameRejected, name)
|
||||
}
|
||||
|
||||
// RollbackInstance deletes an instance that has no users.
|
||||
//
|
||||
// It refuses an instance that has users. Rollback exists to clean up a
|
||||
// half-finished signup, and an instance with users is not half-finished.
|
||||
func RollbackInstance(ctx context.Context, db *mongo.Database, instanceID string) error {
|
||||
n, err := db.Collection("users").CountDocuments(ctx, bson.M{"instance_id": instanceID})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n > 0 {
|
||||
return fmt.Errorf("refusing to roll back instance %s: it has %d user(s)", instanceID, n)
|
||||
}
|
||||
_, err = db.Collection("instances").DeleteOne(ctx, bson.M{"instance_id": instanceID})
|
||||
return err
|
||||
}
|
||||
@@ -1,3 +1,10 @@
|
||||
// Package provision holds the tenant creation rules shared by the control
|
||||
// plane and sitesvc.
|
||||
//
|
||||
// These rules used to be duplicated: the control plane owned one copy and
|
||||
// sitesvc mirrored it by hand. The copies had already drifted — sitesvc retried
|
||||
// on a lost slug race while the control plane returned an error. This package
|
||||
// is the single definition; neither service may reimplement any of it.
|
||||
package provision
|
||||
|
||||
import (
|
||||
@@ -6,39 +13,28 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
/*
|
||||
Slug rules mirrored from the control plane (server/internal/services: Slugify in
|
||||
stepscan.go, reservedSlugs and CreateOrg in orgs.go).
|
||||
|
||||
They live here rather than being imported because sitesvc is a separate module
|
||||
with no dependency on the server. That is a deliberate trade: sitesvc stays
|
||||
small and independent, at the cost of this one duplicated rule set.
|
||||
|
||||
Keep the two in step. If the control plane's slug handling, reserved names or
|
||||
bcrypt cost change, change them here in the same commit nothing enforces the
|
||||
match automatically, and a divergence would create tenants under rules the app
|
||||
does not agree with.
|
||||
*/
|
||||
|
||||
const (
|
||||
MinSlugLength = 3
|
||||
MaxSlugLength = 40
|
||||
BcryptCost = 12
|
||||
)
|
||||
|
||||
var slugStrip = regexp.MustCompile(`[^a-z0-9]+`)
|
||||
|
||||
// ReservedSlugs are subdomain labels the platform needs for itself.
|
||||
var ReservedSlugs = map[string]bool{
|
||||
"www": true, "api": true, "app": true, "admin": true, "auth": true,
|
||||
"install": true, "static": true, "_next": true, "default": true,
|
||||
}
|
||||
|
||||
// Slugify lowercases a name and collapses every run of non-alphanumeric
|
||||
// characters into a single hyphen, trimming hyphens from both ends.
|
||||
func Slugify(name string) string {
|
||||
s := strings.ToLower(name)
|
||||
s = slugStrip.ReplaceAllString(s, "-")
|
||||
return strings.Trim(s, "-")
|
||||
}
|
||||
|
||||
// BaseSlug turns a name into a validated slug stem, or explains why it cannot.
|
||||
func BaseSlug(name string) (string, error) {
|
||||
base := Slugify(name)
|
||||
if len(base) < MinSlugLength {
|
||||
@@ -53,6 +49,8 @@ func BaseSlug(name string) (string, error) {
|
||||
return base, nil
|
||||
}
|
||||
|
||||
// NextSlug returns the candidate slug for a given attempt. Attempt 1 is the
|
||||
// base itself; later attempts append a counter.
|
||||
func NextSlug(base string, attempt int) string {
|
||||
if attempt < 2 {
|
||||
return base
|
||||
@@ -0,0 +1,65 @@
|
||||
package provision
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/mrhid6/vantage/shared/models"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// BcryptCost is the work factor for every password hash Vantage writes.
|
||||
// Changing it changes nothing about existing hashes, which carry their own cost.
|
||||
const BcryptCost = 12
|
||||
|
||||
// ErrEmailTaken is returned when the unique index on users.email rejects an insert.
|
||||
var ErrEmailTaken = errors.New("email already registered")
|
||||
|
||||
// CreateUser hashes password and inserts the user. An empty password leaves the
|
||||
// hash empty, which is how OIDC users are stored.
|
||||
func CreateUser(ctx context.Context, db *mongo.Database, instanceID, email, password, role, authSource string) (*models.User, error) {
|
||||
var hash string
|
||||
if password != "" {
|
||||
b, err := bcrypt.GenerateFromPassword([]byte(password), BcryptCost)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
hash = string(b)
|
||||
}
|
||||
return CreateUserWithHash(ctx, db, instanceID, email, hash, role, authSource)
|
||||
}
|
||||
|
||||
// CreateUserWithHash inserts a user whose password was already hashed
|
||||
// elsewhere. sitesvc hashes at signup and only holds the hash by the time the
|
||||
// verification link is opened.
|
||||
func CreateUserWithHash(ctx context.Context, db *mongo.Database, instanceID, email, passwordHash, role, authSource string) (*models.User, error) {
|
||||
email = strings.ToLower(strings.TrimSpace(email))
|
||||
if email == "" {
|
||||
return nil, fmt.Errorf("email required")
|
||||
}
|
||||
if !models.ValidRole(role) {
|
||||
return nil, fmt.Errorf("invalid role %q", role)
|
||||
}
|
||||
|
||||
u := &models.User{
|
||||
UserID: uuid.NewString(),
|
||||
InstanceID: instanceID,
|
||||
Email: email,
|
||||
PasswordHash: passwordHash,
|
||||
Role: role,
|
||||
AuthSource: authSource,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
}
|
||||
if _, err := db.Collection("users").InsertOne(ctx, u); err != nil {
|
||||
if mongo.IsDuplicateKeyError(err) {
|
||||
return nil, ErrEmailTaken
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
+4
-4
@@ -14,7 +14,7 @@ export default function OverviewPage() {
|
||||
</p>
|
||||
<div className="hero__acts">
|
||||
<Link className="btn btn--solid" href="/start">
|
||||
Create your organisation
|
||||
Create your instance
|
||||
</Link>
|
||||
<Link className="btn btn--line" href="/platform">
|
||||
What it does
|
||||
@@ -133,8 +133,8 @@ export default function OverviewPage() {
|
||||
<div className="flow">
|
||||
<div className="flow__c">
|
||||
<span className="flow__n">FIRST</span>
|
||||
<h3>Create an organisation</h3>
|
||||
<p>You become the owner. Everything inside is invisible to every other organisation.</p>
|
||||
<h3>Create an instance</h3>
|
||||
<p>You become the owner. Everything inside is invisible to every other instance.</p>
|
||||
</div>
|
||||
<div className="flow__c">
|
||||
<span className="flow__n">THEN</span>
|
||||
@@ -168,7 +168,7 @@ export default function OverviewPage() {
|
||||
<div className="card card--cta">
|
||||
<div style={{ maxWidth: "48ch" }}>
|
||||
<h2 style={{ fontSize: "var(--s-1)" }}>Three servers, free, no card.</h2>
|
||||
<p style={{ color: "var(--ink-2)", marginTop: "0.4rem", fontSize: "0.94rem" }}>Create an organisation, install one agent, and watch a key land on a real box.</p>
|
||||
<p style={{ color: "var(--ink-2)", marginTop: "0.4rem", fontSize: "0.94rem" }}>Create an instance, install one agent, and watch a key land on a real box.</p>
|
||||
</div>
|
||||
<Link className="btn btn--solid" href="/start">
|
||||
Get started
|
||||
|
||||
@@ -82,13 +82,13 @@ export default function PlatformPage() {
|
||||
|
||||
<section className="rail band">
|
||||
<span className="tag">Tenancy and identity</span>
|
||||
<h2 style={{ fontSize: "var(--s-2)", marginTop: "0.7rem", maxWidth: "22ch" }}>Organisations are the boundary.</h2>
|
||||
<h2 style={{ fontSize: "var(--s-2)", marginTop: "0.7rem", maxWidth: "22ch" }}>Instances are the boundary.</h2>
|
||||
<div className="caps">
|
||||
<article className="cap">
|
||||
<span className="cap__k">Isolation</span>
|
||||
<h3>Scoped at the query</h3>
|
||||
<p>
|
||||
Every server, key, workflow, monitor and secret belongs to an organisation, and every lookup is filtered by it. Uniqueness constraints are enforced by the database, not by
|
||||
Every server, key, workflow, monitor and secret belongs to an instance, and every lookup is filtered by it. Uniqueness constraints are enforced by the database, not by
|
||||
application logic.
|
||||
</p>
|
||||
</article>
|
||||
@@ -99,8 +99,8 @@ export default function PlatformPage() {
|
||||
</article>
|
||||
<article className="cap">
|
||||
<span className="cap__k">Identity</span>
|
||||
<h3>Local or OIDC, per organisation</h3>
|
||||
<p>Sign in with email and password, or connect your own provider. Each organisation configures its own issuer and client.</p>
|
||||
<h3>Local or OIDC, per instance</h3>
|
||||
<p>Sign in with email and password, or connect your own provider. Each instance configures its own issuer and client.</p>
|
||||
</article>
|
||||
<article className="cap">
|
||||
<span className="cap__k">Sessions</span>
|
||||
|
||||
@@ -45,7 +45,7 @@ export default function PricingPage() {
|
||||
<ul>
|
||||
<li>Up to 3 servers</li>
|
||||
<li>Keys, workflows and monitors</li>
|
||||
<li>One member, one organisation</li>
|
||||
<li>One member, one instance</li>
|
||||
<li>Community support</li>
|
||||
</ul>
|
||||
<Link className="btn btn--line" href="/start">
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { Metadata } from "next";
|
||||
import { OrgForm } from "@/components/OrgForm";
|
||||
import { InstanceForm } from "@/components/InstanceForm";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Vantage Cloud",
|
||||
description: "An organisation owns its servers, keys, workflows, monitors and secrets. Free for three servers, hosted or self-hosted.",
|
||||
description: "An instance owns its servers, keys, workflows, monitors and secrets. Free for three servers, hosted or self-hosted.",
|
||||
};
|
||||
|
||||
export default function StartPage() {
|
||||
@@ -12,14 +12,14 @@ export default function StartPage() {
|
||||
<div className="split">
|
||||
<div>
|
||||
<span className="tag">Vantage Cloud</span>
|
||||
<h1 style={{ fontSize: "var(--s-3)", margin: "0.8rem 0 1rem", maxWidth: "15ch" }}>Set up your organisation.</h1>
|
||||
<h1 style={{ fontSize: "var(--s-3)", margin: "0.8rem 0 1rem", maxWidth: "15ch" }}>Set up your instance.</h1>
|
||||
<p className="lede" style={{ fontSize: "var(--s-0)" }}>
|
||||
An organisation owns its servers, keys, workflows, monitors and secrets. Nothing inside it is visible to any other organisation. Confirm your email and it is created with you
|
||||
An instance owns its servers, keys, workflows, monitors and secrets. Nothing inside it is visible to any other instance. Confirm your email and it is created with you
|
||||
as its owner.
|
||||
</p>
|
||||
|
||||
<div className="card" style={{ marginTop: "1.9rem" }}>
|
||||
<OrgForm />
|
||||
<InstanceForm />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -30,7 +30,7 @@ export default function StartPage() {
|
||||
<span className="spec__k">FIRST</span>
|
||||
<div>
|
||||
<h3>Confirm your email</h3>
|
||||
<p>We send a link that works once. Your organisation is created when you open it, not before.</p>
|
||||
<p>We send a link that works once. Your instance is created when you open it, not before.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="spec">
|
||||
|
||||
@@ -14,7 +14,7 @@ function slugify(value: string) {
|
||||
.replace(/^-|-$/g, "");
|
||||
}
|
||||
|
||||
export function OrgForm() {
|
||||
export function InstanceForm() {
|
||||
const [slug, setSlug] = useState("");
|
||||
const [result, setResult] = useState<SubmitResult>({ state: "idle" });
|
||||
const sending = result.state === "sending";
|
||||
@@ -25,7 +25,7 @@ export function OrgForm() {
|
||||
setResult({ state: "sending" });
|
||||
setResult(
|
||||
await submitSignup({
|
||||
org_name: String(data.get("org_name") ?? ""),
|
||||
instance_name: String(data.get("instance_name") ?? ""),
|
||||
email: String(data.get("email") ?? ""),
|
||||
password: String(data.get("password") ?? ""),
|
||||
website: String(data.get("website") ?? ""),
|
||||
@@ -38,7 +38,7 @@ export function OrgForm() {
|
||||
<div role="status">
|
||||
<h2 style={{ fontSize: "var(--s-1)" }}>Check your email.</h2>
|
||||
<p style={{ color: "var(--ink-2)", marginTop: "0.5rem" }}>
|
||||
We sent a confirmation link. Open it and <b>{slug || "your organisation"}</b> is created with you as its owner. The link works once and expires in 24 hours.
|
||||
We sent a confirmation link. Open it and <b>{slug || "your instance"}</b> is created with you as its owner. The link works once and expires in 24 hours.
|
||||
</p>
|
||||
<p style={{ color: "var(--ink-3)", marginTop: "0.75rem", fontSize: "0.88rem" }}>
|
||||
Nothing exists until you confirm if the email does not arrive, start again or contact support@hostxtra.co.uk.
|
||||
@@ -54,14 +54,14 @@ export function OrgForm() {
|
||||
<Honeypot />
|
||||
|
||||
<div className="field">
|
||||
<label htmlFor="o-org">Organisation name</label>
|
||||
<input id="o-org" name="org_name" type="text" placeholder="Northgate Systems" required onChange={(e) => setSlug(slugify(e.target.value))} aria-describedby="o-org-err" />
|
||||
<label htmlFor="o-instance">Instance name</label>
|
||||
<input id="o-instance" name="instance_name" type="text" placeholder="Northgate Systems" required onChange={(e) => setSlug(slugify(e.target.value))} aria-describedby="o-instance-err" />
|
||||
<span className="hostline">
|
||||
<b>{slug || "your-org"}</b>.vantage.hostxtra.co.uk
|
||||
<b>{slug || "your-instance"}</b>.vantage.hostxtra.co.uk
|
||||
</span>
|
||||
{fieldError("org_name") && (
|
||||
<small id="o-org-err" className="field__err">
|
||||
{fieldError("org_name")}
|
||||
{fieldError("instance_name") && (
|
||||
<small id="o-instance-err" className="field__err">
|
||||
{fieldError("instance_name")}
|
||||
</small>
|
||||
)}
|
||||
</div>
|
||||
+1
-1
@@ -56,7 +56,7 @@ export async function submitContact(fields: { name: string; email: string; serve
|
||||
return post(`${SITE_API}/api/contact`, fields);
|
||||
}
|
||||
|
||||
export async function submitSignup(fields: { org_name: string; email: string; password: string; website: string }): Promise<SubmitResult> {
|
||||
export async function submitSignup(fields: { instance_name: string; email: string; password: string; website: string }): Promise<SubmitResult> {
|
||||
if (!SITE_API) {
|
||||
return {
|
||||
state: "error",
|
||||
|
||||
Vendored
+5
-5
@@ -1,6 +1,6 @@
|
||||
/
|
||||
/
|
||||
import "./.next/dev/types/routes.d.ts";
|
||||
|
||||
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
|
||||
+8
-5
@@ -1,13 +1,16 @@
|
||||
# Context is the repository root; sitesvc depends on the shared module.
|
||||
FROM golang:1.26-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
WORKDIR /src
|
||||
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
COPY shared/go.mod shared/go.sum ./shared/
|
||||
COPY sitesvc/go.mod sitesvc/go.sum ./sitesvc/
|
||||
RUN cd sitesvc && go mod download
|
||||
|
||||
COPY . .
|
||||
COPY shared/ ./shared/
|
||||
COPY sitesvc/ ./sitesvc/
|
||||
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /out/sitesvc ./cmd
|
||||
RUN cd sitesvc && CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /out/sitesvc ./cmd
|
||||
|
||||
FROM alpine:3.20 AS runner
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user