Compare commits
6
Commits
main
..
15bc3c40bd
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
15bc3c40bd | ||
|
|
13173185ce | ||
|
|
4639915827 | ||
|
|
55366f8d47 | ||
|
|
3589a40cde | ||
|
|
bb2c638fb4 |
@@ -101,7 +101,7 @@ jobs:
|
|||||||
|
|
||||||
- uses: docker/login-action@v3
|
- uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
registry: repo.breakpilot.com
|
registry: registry.breakpilot.com
|
||||||
username: ${{ secrets.REGISTRY_USER }}
|
username: ${{ secrets.REGISTRY_USER }}
|
||||||
password: ${{ secrets.REGISTRY_PASS }}
|
password: ${{ secrets.REGISTRY_PASS }}
|
||||||
|
|
||||||
@@ -109,12 +109,12 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
push: true
|
push: true
|
||||||
tags: |
|
tags: |
|
||||||
repo.breakpilot.com/breakpilot/${{ github.event.repository.name }}:sha-${{ github.sha }}
|
registry.breakpilot.com/${{ github.event.repository.name }}:sha-${{ github.sha }}
|
||||||
repo.breakpilot.com/breakpilot/${{ github.event.repository.name }}:env-stage
|
registry.breakpilot.com/${{ github.event.repository.name }}:env-stage
|
||||||
|
|
||||||
- uses: anchore/sbom-action@v0
|
- uses: anchore/sbom-action@v0
|
||||||
with:
|
with:
|
||||||
image: repo.breakpilot.com/breakpilot/${{ github.event.repository.name }}:sha-${{ github.sha }}
|
image: registry.breakpilot.com/${{ github.event.repository.name }}:sha-${{ github.sha }}
|
||||||
|
|
||||||
- name: orca deploy stage
|
- name: orca deploy stage
|
||||||
run: orca apply --env=stage --image-tag=sha-${{ github.sha }}
|
run: orca apply --env=stage --image-tag=sha-${{ github.sha }}
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ jobs:
|
|||||||
|
|
||||||
- name: verify stage soak (>= 24h on this image)
|
- name: verify stage soak (>= 24h on this image)
|
||||||
run: |
|
run: |
|
||||||
IMG=repo.breakpilot.com/breakpilot/${{ github.event.repository.name }}:env-stage
|
IMG=registry.breakpilot.com/${{ github.event.repository.name }}:env-stage
|
||||||
SOAK_SECONDS=$(orca image-age --env=stage --image $IMG)
|
SOAK_SECONDS=$(orca image-age --env=stage --image $IMG)
|
||||||
if [ "$SOAK_SECONDS" -lt 86400 ]; then
|
if [ "$SOAK_SECONDS" -lt 86400 ]; then
|
||||||
echo "Stage soak only $SOAK_SECONDS s, < 24h. Aborting."
|
echo "Stage soak only $SOAK_SECONDS s, < 24h. Aborting."
|
||||||
@@ -34,12 +34,12 @@ jobs:
|
|||||||
- name: re-tag image as semver + env-prod
|
- name: re-tag image as semver + env-prod
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
registry: repo.breakpilot.com
|
registry: registry.breakpilot.com
|
||||||
username: ${{ secrets.REGISTRY_USER }}
|
username: ${{ secrets.REGISTRY_USER }}
|
||||||
password: ${{ secrets.REGISTRY_PASS }}
|
password: ${{ secrets.REGISTRY_PASS }}
|
||||||
|
|
||||||
- run: |
|
- run: |
|
||||||
IMG=repo.breakpilot.com/breakpilot/${{ github.event.repository.name }}
|
IMG=registry.breakpilot.com/${{ github.event.repository.name }}
|
||||||
docker pull $IMG:env-stage
|
docker pull $IMG:env-stage
|
||||||
docker tag $IMG:env-stage $IMG:v${{ steps.v.outputs.version }}
|
docker tag $IMG:env-stage $IMG:v${{ steps.v.outputs.version }}
|
||||||
docker tag $IMG:env-stage $IMG:env-prod
|
docker tag $IMG:env-stage $IMG:env-prod
|
||||||
@@ -67,7 +67,7 @@ jobs:
|
|||||||
curl -X POST -H "Authorization: token ${{ secrets.GITEA_TOKEN }}" \
|
curl -X POST -H "Authorization: token ${{ secrets.GITEA_TOKEN }}" \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d "$(jq -Rs '{tag_name:"v${{ steps.v.outputs.version }}", name:"v${{ steps.v.outputs.version }}", body:.}' < RELEASE_NOTES.md)" \
|
-d "$(jq -Rs '{tag_name:"v${{ steps.v.outputs.version }}", name:"v${{ steps.v.outputs.version }}", body:.}' < RELEASE_NOTES.md)" \
|
||||||
https://git.breakpilot.com/api/v1/repos/${{ github.repository }}/releases
|
https://gitea.meghsakha.com/api/v1/repos/${{ github.repository }}/releases
|
||||||
|
|
||||||
rollback-on-failure:
|
rollback-on-failure:
|
||||||
needs: promote
|
needs: promote
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ Generated section is appended on release tag via `git-cliff` (see `.gitea/workfl
|
|||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
- feat(store): CreateTenant defaults trial_ends_at to NOW()+14d for customer kind; demo kind gets status='demo' and no trial end
|
|
||||||
- feat(keycloak): M4.3 — internal/keycloak adapter (Admin API: org create + IT_ADMIN invite + execute-actions-email + attribute sync). admin_email on POST /v1/tenants triggers KC provisioning; failures emit keycloak.provision_failed audit but don't roll back. POST /v1/internal/keycloak/claims resolves the current claim bundle for a tenant.
|
- feat(keycloak): M4.3 — internal/keycloak adapter (Admin API: org create + IT_ADMIN invite + execute-actions-email + attribute sync). admin_email on POST /v1/tenants triggers KC provisioning; failures emit keycloak.provision_failed audit but don't roll back. POST /v1/internal/keycloak/claims resolves the current claim bundle for a tenant.
|
||||||
- feat(api): M4.2 — full REST surface (tenants CRUD + lifecycle, catalog, entitlements, API keys w/ argon2 hashing, audit query). pgx-backed Postgres store; in-memory fallback when DATABASE_URL is empty. OpenAPI 3.1 spec at openapi.yaml with kin-openapi contract test.
|
- feat(api): M4.2 — full REST surface (tenants CRUD + lifecycle, catalog, entitlements, API keys w/ argon2 hashing, audit query). pgx-backed Postgres store; in-memory fallback when DATABASE_URL is empty. OpenAPI 3.1 spec at openapi.yaml with kin-openapi contract test.
|
||||||
- feat(schema): M4.1 — golang-migrate migrations for tenants + tenant_projects + tenant_products + tenant_idp_config + api_keys + audit_log; cmd/migrate binary; testcontainers round-trip + seed + slug-constraint tests
|
- feat(schema): M4.1 — golang-migrate migrations for tenants + tenant_projects + tenant_products + tenant_idp_config + api_keys + audit_log; cmd/migrate binary; testcontainers round-trip + seed + slug-constraint tests
|
||||||
|
|||||||
+11
-13
@@ -4,7 +4,6 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -88,23 +87,22 @@ func (s *statusRecorder) WriteHeader(c int) {
|
|||||||
func clientIP(r *http.Request) string {
|
func clientIP(r *http.Request) string {
|
||||||
if fwd := r.Header.Get("X-Forwarded-For"); fwd != "" {
|
if fwd := r.Header.Get("X-Forwarded-For"); fwd != "" {
|
||||||
if i := strings.IndexByte(fwd, ','); i > 0 {
|
if i := strings.IndexByte(fwd, ','); i > 0 {
|
||||||
return stripBrackets(strings.TrimSpace(fwd[:i]))
|
return strings.TrimSpace(fwd[:i])
|
||||||
}
|
}
|
||||||
return stripBrackets(strings.TrimSpace(fwd))
|
return strings.TrimSpace(fwd)
|
||||||
}
|
}
|
||||||
if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
|
if host, _, ok := splitHostPort(r.RemoteAddr); ok {
|
||||||
// net.SplitHostPort returns IPv6 without brackets already.
|
|
||||||
return host
|
return host
|
||||||
}
|
}
|
||||||
return stripBrackets(r.RemoteAddr)
|
return r.RemoteAddr
|
||||||
}
|
}
|
||||||
|
|
||||||
// stripBrackets removes the `[...]` wrapping IPv6 hosts pick up from
|
// splitHostPort is a port-tolerant version of net.SplitHostPort that doesn't
|
||||||
// net/http's RemoteAddr in some Go versions, since Postgres `inet` rejects
|
// error on missing port.
|
||||||
// `[::1]` but accepts `::1`.
|
func splitHostPort(s string) (string, string, bool) {
|
||||||
func stripBrackets(s string) string {
|
i := strings.LastIndexByte(s, ':')
|
||||||
if len(s) >= 2 && s[0] == '[' && s[len(s)-1] == ']' {
|
if i < 0 {
|
||||||
return s[1 : len(s)-1]
|
return s, "", false
|
||||||
}
|
}
|
||||||
return s
|
return s[:i], s[i+1:], true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package server_test
|
|||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
|
||||||
|
|
||||||
"gitea.meghsakha.com/platform/tenant-registry/internal/store"
|
"gitea.meghsakha.com/platform/tenant-registry/internal/store"
|
||||||
)
|
)
|
||||||
@@ -120,42 +119,3 @@ func TestCancelTenant(t *testing.T) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCreateTenant_setsTrialEndsAt(t *testing.T) {
|
|
||||||
eachStore(t, func(t *testing.T, h *testHarness) {
|
|
||||||
_, body := h.do("POST", "/v1/tenants", map[string]any{
|
|
||||||
"slug": "trial-ends-co", "name": "Trial Ends Co.",
|
|
||||||
})
|
|
||||||
out := decode[struct {
|
|
||||||
Tenant *store.Tenant `json:"tenant"`
|
|
||||||
}](t, body)
|
|
||||||
if out.Tenant.Status != "trial" {
|
|
||||||
t.Fatalf("status = %q, want trial", out.Tenant.Status)
|
|
||||||
}
|
|
||||||
if out.Tenant.TrialEndsAt == nil {
|
|
||||||
t.Fatal("trial_ends_at is nil; should be ~14 days from now")
|
|
||||||
}
|
|
||||||
// Sanity-check: ends_at is in the future, within 13.5-14.5 days.
|
|
||||||
delta := time.Until(*out.Tenant.TrialEndsAt)
|
|
||||||
if delta < 13*24*time.Hour || delta > 15*24*time.Hour {
|
|
||||||
t.Errorf("trial_ends_at offset = %v, want ~14d", delta)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCreateTenant_demoKindHasNoTrialEnd(t *testing.T) {
|
|
||||||
eachStore(t, func(t *testing.T, h *testHarness) {
|
|
||||||
_, body := h.do("POST", "/v1/tenants", map[string]any{
|
|
||||||
"slug": "demo-co", "name": "Demo", "kind": "demo",
|
|
||||||
})
|
|
||||||
out := decode[struct {
|
|
||||||
Tenant *store.Tenant `json:"tenant"`
|
|
||||||
}](t, body)
|
|
||||||
if out.Tenant.Status != "demo" {
|
|
||||||
t.Errorf("status = %q, want demo", out.Tenant.Status)
|
|
||||||
}
|
|
||||||
if out.Tenant.TrialEndsAt != nil {
|
|
||||||
t.Errorf("trial_ends_at = %v, want nil for demo kind", out.Tenant.TrialEndsAt)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -69,26 +69,16 @@ func (m *Memory) CreateTenant(_ context.Context, in TenantCreate) (*Tenant, erro
|
|||||||
return nil, ErrConflict
|
return nil, ErrConflict
|
||||||
}
|
}
|
||||||
now := time.Now().UTC()
|
now := time.Now().UTC()
|
||||||
kind := firstNonEmpty(in.Kind, "customer")
|
|
||||||
status := "trial"
|
|
||||||
var trialEnds *time.Time
|
|
||||||
if kind == "demo" {
|
|
||||||
status = "demo"
|
|
||||||
} else {
|
|
||||||
end := now.Add(14 * 24 * time.Hour)
|
|
||||||
trialEnds = &end
|
|
||||||
}
|
|
||||||
t := &Tenant{
|
t := &Tenant{
|
||||||
ID: uuid.NewString(),
|
ID: uuid.NewString(),
|
||||||
Slug: in.Slug,
|
Slug: in.Slug,
|
||||||
Name: in.Name,
|
Name: in.Name,
|
||||||
Status: status,
|
Status: "trial",
|
||||||
Kind: kind,
|
Kind: firstNonEmpty(in.Kind, "customer"),
|
||||||
Plan: firstNonEmpty(in.Plan, "starter"),
|
Plan: firstNonEmpty(in.Plan, "starter"),
|
||||||
SalesOwner: in.SalesOwner,
|
SalesOwner: in.SalesOwner,
|
||||||
TrialEndsAt: trialEnds,
|
CreatedAt: now,
|
||||||
CreatedAt: now,
|
UpdatedAt: now,
|
||||||
UpdatedAt: now,
|
|
||||||
}
|
}
|
||||||
m.tenants[t.ID] = t
|
m.tenants[t.ID] = t
|
||||||
m.bySlug[t.Slug] = t.ID
|
m.bySlug[t.Slug] = t.ID
|
||||||
|
|||||||
@@ -90,20 +90,9 @@ func scanTenant(row pgx.Row) (*Tenant, error) {
|
|||||||
func (p *Postgres) CreateTenant(ctx context.Context, in TenantCreate) (*Tenant, error) {
|
func (p *Postgres) CreateTenant(ctx context.Context, in TenantCreate) (*Tenant, error) {
|
||||||
kind := firstNonEmpty(in.Kind, "customer")
|
kind := firstNonEmpty(in.Kind, "customer")
|
||||||
plan := firstNonEmpty(in.Plan, "starter")
|
plan := firstNonEmpty(in.Plan, "starter")
|
||||||
// Default status = 'trial'; set trial_ends_at = NOW() + 14 days so the
|
|
||||||
// portal's trial banner has a real countdown to render. Demo tenants
|
|
||||||
// (kind=demo) get status='demo' and no trial_ends_at — that's set by
|
|
||||||
// the M13.2 demo provisioning path.
|
|
||||||
row := p.pool.QueryRow(ctx,
|
row := p.pool.QueryRow(ctx,
|
||||||
`INSERT INTO tenants (slug, name, kind, plan, status, sales_owner, trial_ends_at)
|
`INSERT INTO tenants (slug, name, kind, plan, sales_owner)
|
||||||
VALUES (
|
VALUES ($1, $2, $3::tenant_kind, $4, NULLIF($5, ''))
|
||||||
$1, $2, $3::tenant_kind, $4,
|
|
||||||
CASE WHEN $3::tenant_kind = 'demo' THEN 'demo'::tenant_status
|
|
||||||
ELSE 'trial'::tenant_status END,
|
|
||||||
NULLIF($5, ''),
|
|
||||||
CASE WHEN $3::tenant_kind = 'demo' THEN NULL
|
|
||||||
ELSE NOW() + INTERVAL '14 days' END
|
|
||||||
)
|
|
||||||
RETURNING id::text, slug, name, status::text, kind::text, plan,
|
RETURNING id::text, slug, name, status::text, kind::text, plan,
|
||||||
COALESCE(erp_customer_id,''), COALESCE(stripe_cust_id,''),
|
COALESCE(erp_customer_id,''), COALESCE(stripe_cust_id,''),
|
||||||
trial_ends_at, contract_start, contract_end, COALESCE(sales_owner,''),
|
trial_ends_at, contract_start, contract_end, COALESCE(sales_owner,''),
|
||||||
|
|||||||
Reference in New Issue
Block a user