fix(go): resync tenant id on license change; show license error on any error while license validation;

This commit is contained in:
Vishal Dalwadi
2026-09-21 12:59:49 +05:30
parent 358d6737bf
commit cf1acff10b
6 changed files with 67 additions and 91 deletions
+2 -2
View File
@@ -10,9 +10,9 @@ import (
"github.com/gravitl/netmaker/scope"
)
var SyncOrgAndTenants = CreateLocalDefaults
var MigrateOrgAndTenants = migrateOrgAndTenants
func CreateLocalDefaults(ctx context.Context) error {
func migrateOrgAndTenants(ctx context.Context) error {
org, err := EnsureLocalOrganization(ctx)
if err != nil {
return err
+10 -10
View File
@@ -122,7 +122,7 @@ func TestToSQLSchema_StuckServerCompletesV160AndV170(t *testing.T) {
ctx := setupMigrationTest(t)
networkName, nodeID := seedLegacyKVData(t, ctx)
require.NoError(t, CreateLocalDefaults(ctx))
require.NoError(t, migrateOrgAndTenants(ctx))
markMigrationJobComplete(t, ctx, "migration-multitenancy")
markMigrationJobComplete(t, ctx, "migration-v1.5.1")
@@ -179,7 +179,7 @@ func TestToSQLSchema_StuckServerCompletesV160AndV170(t *testing.T) {
func TestToSQLSchema_SkipsCompletedPreMTJobs(t *testing.T) {
ctx := setupMigrationTest(t)
require.NoError(t, CreateLocalDefaults(ctx))
require.NoError(t, migrateOrgAndTenants(ctx))
markMigrationJobComplete(t, ctx, "migration-multitenancy")
markMigrationJobComplete(t, ctx, "migration-v1.5.1")
markMigrationJobComplete(t, ctx, "migration-v1.6.0")
@@ -200,19 +200,19 @@ func TestToSQLSchema_SkipsCompletedPreMTJobs(t *testing.T) {
require.Len(t, tenants, 1)
}
// TestMigrateV1_7_0_UsesSyncOrgAndTenantsHook ensures v1.7.0 step 0 goes through
// SyncOrgAndTenants (EE overrides this with license sync) rather than always
// calling CreateLocalDefaults, which breaks MSP installs that need multiple
// TestMigrateV1_7_0_UsesMigrateOrgAndTenantsHook ensures v1.7.0 step 0 goes through
// MigrateOrgAndTenants (EE overrides this with license sync) rather than always
// calling migrateOrgAndTenants, which breaks MSP installs that need multiple
// tenants from the account server.
func TestMigrateV1_7_0_UsesSyncOrgAndTenantsHook(t *testing.T) {
func TestMigrateV1_7_0_UsesMigrateOrgAndTenantsHook(t *testing.T) {
ctx := setupMigrationTest(t)
markMigrationJobComplete(t, ctx, migrationJobV160)
orig := SyncOrgAndTenants
t.Cleanup(func() { SyncOrgAndTenants = orig })
orig := MigrateOrgAndTenants
t.Cleanup(func() { MigrateOrgAndTenants = orig })
called := false
SyncOrgAndTenants = func(ctx context.Context) error {
MigrateOrgAndTenants = func(ctx context.Context) error {
called = true
org := &schema.Organization{
ID: "license-org-id",
@@ -231,7 +231,7 @@ func TestMigrateV1_7_0_UsesSyncOrgAndTenantsHook(t *testing.T) {
}
require.NoError(t, migrateV1_7_0(ctx))
assert.True(t, called, "expected SyncOrgAndTenants hook to run")
assert.True(t, called, "expected MigrateOrgAndTenants hook to run")
tenants, err := (&schema.Tenant{}).List(ctx)
require.NoError(t, err)
+3 -3
View File
@@ -35,10 +35,10 @@ const (
func migrateV1_7_0(ctx context.Context) error {
// Step 0: bootstrap org/tenants (was migration-multitenancy).
// Goes through SyncOrgAndTenants so EE/MSP can sync from license validation
// Goes through MigrateOrgAndTenants so EE/MSP can sync from license validation
// instead of always creating a local UUID default tenant. CE keeps the
// CreateLocalDefaults default; idempotent when org/tenant already exist.
if err := SyncOrgAndTenants(ctx); err != nil {
// migrateOrgAndTenants default; idempotent when org/tenant already exist.
if err := MigrateOrgAndTenants(ctx); err != nil {
return err
}
+1 -1
View File
@@ -70,7 +70,7 @@ func InitPro() {
proControllers.IntegrationHandlers,
)
controller.ListRoles = proControllers.ListRoles
migrate.SyncOrgAndTenants = license.SyncOrgAndTenants
migrate.MigrateOrgAndTenants = license.MigrateOrgAndTenants
servercfg.ErrLicenseValidation = license.ErrLicenseValidation
logic.EnterpriseCheckFuncs = append(logic.EnterpriseCheckFuncs, func(ctx context.Context, wg *sync.WaitGroup) {
logger.Log(0, "starting license checker")
-5
View File
@@ -9,11 +9,6 @@ import (
"github.com/gravitl/netmaker/schema"
)
func hasCachedResponse(ctx context.Context) bool {
cached := &schema.Internal{Key: schema.InternalKey_LicenseValidationCachedResponse}
return cached.Get(ctx) == nil
}
var cachedResponse atomic.Pointer[ValidatedLicense]
func cacheResponse(ctx context.Context, response []byte) error {
+51 -70
View File
@@ -42,21 +42,22 @@ func AddLicenseHooks() {
}
}
func SyncOrgAndTenants(ctx context.Context) error {
licenseResponse, isCachedResp, err := fetchValidatedLicense(ctx)
func MigrateOrgAndTenants(ctx context.Context) error {
if err := clearCachedResponse(ctx); err != nil {
return err
}
// the cache was just cleared, so the response is never a cached one.
licenseResponse, _, err := fetchValidatedLicense(ctx)
if err != nil {
return err
}
if isCachedResp {
return nil
}
return syncOrgAndTenantsFromResponse(ctx, licenseResponse, false)
return syncOrgAndTenantsFromResponse(ctx, licenseResponse)
}
func syncOrgAndTenantsFromResponse(ctx context.Context, licenseResponse ValidatedLicense, hadCache bool) error {
if hadCache {
func syncOrgAndTenantsFromResponse(ctx context.Context, licenseResponse ValidatedLicense) error {
if licenseResponse.Organization.ID != "" {
return upsertOrgAndTenants(ctx, licenseResponse)
}
return reconcileOrgAndTenants(ctx, licenseResponse)
@@ -84,15 +85,6 @@ func upsertOrgAndTenants(ctx context.Context, licenseResponse ValidatedLicense)
}
func upsertOrganization(ctx context.Context, licenseOrg LicenseOrg) (string, error) {
if licenseOrg.ID == "" {
// single-tenant PRO: the license carries no organization.
org, err := migrate.EnsureLocalOrganization(ctx)
if err != nil {
return "", err
}
return org.ID, nil
}
org := &schema.Organization{ID: licenseOrg.ID}
err := org.Get(ctx)
if err != nil {
@@ -160,62 +152,20 @@ func teardownDeletedTenant(ctx context.Context, tenantID string) error {
}
func reconcileOrgAndTenants(ctx context.Context, licenseResponse ValidatedLicense) error {
orgID, err := reconcileOrganization(ctx, licenseResponse.Organization)
// non-MSP: the license carries no organization, so the local one is used.
org, err := migrate.EnsureLocalOrganization(ctx)
if err != nil {
return fmt.Errorf("failed to reconcile organization: %w", err)
}
if err := reconcileTenants(ctx, licenseResponse.Tenants, orgID); err != nil {
if err := reconcileTenants(ctx, licenseResponse.Tenants, org.ID); err != nil {
return fmt.Errorf("failed to reconcile tenants: %w", err)
}
return nil
}
func reconcileOrganization(ctx context.Context, licenseOrg LicenseOrg) (string, error) {
if licenseOrg.ID == "" {
org, err := migrate.EnsureLocalOrganization(ctx)
if err != nil {
return "", err
}
return org.ID, nil
}
orgs, err := (&schema.Organization{}).ListAll(ctx)
if err != nil {
return "", err
}
switch len(orgs) {
case 0:
org := &schema.Organization{ID: licenseOrg.ID, Name: licenseOrg.Name, Metadata: licenseOrg.Metadata}
if err := org.Create(ctx); err != nil {
return "", err
}
return org.ID, nil
case 1:
existing := orgs[0]
if existing.ID != licenseOrg.ID {
if err := migrate.RekeyOrganization(ctx, existing.ID, licenseOrg.ID); err != nil {
return "", err
}
}
org := &schema.Organization{ID: licenseOrg.ID, Name: licenseOrg.Name, Metadata: licenseOrg.Metadata}
if err := org.Update(ctx); err != nil {
return "", err
}
return licenseOrg.ID, nil
default:
return "", fmt.Errorf("cannot reconcile license organization: %d local organizations already exist", len(orgs))
}
}
func reconcileTenants(ctx context.Context, licenseTenants []LicenseTenant, orgID string) error {
existing, err := (&schema.Tenant{}).List(ctx)
if err != nil {
return err
}
activeTenants := make([]LicenseTenant, 0, len(licenseTenants))
for _, licenseTenant := range licenseTenants {
if licenseTenant.Status == TenantStatusDeleted {
@@ -227,6 +177,12 @@ func reconcileTenants(ctx context.Context, licenseTenants []LicenseTenant, orgID
activeTenants = append(activeTenants, licenseTenant)
}
// list after teardown so a tenant that was just removed isn't counted.
existing, err := (&schema.Tenant{}).List(ctx)
if err != nil {
return err
}
switch len(existing) {
case 0:
// nothing pre-existing locally -- nothing to rekey.
@@ -333,14 +289,16 @@ func getLicensePublicKey(licensePubKeyEncoded string) (*[32]byte, error) {
// if license is free_tier and limits exceeds, then function should error
// if license is not valid, function should error
func ValidateLicense(ctx context.Context, clearCache bool) (err error) {
var isCachedResp bool
defer func() {
if err != nil {
err = fmt.Errorf("%w: %s", errValidation, err.Error())
setLicenseInvalidErr(err)
} else if !isCachedResp {
licenseInvalidErr.Store(nil)
}
}()
hadCache := hasCachedResponse(ctx)
if clearCache {
err = clearCachedResponse(ctx)
if err != nil {
@@ -356,7 +314,7 @@ func ValidateLicense(ctx context.Context, clearCache bool) (err error) {
return
}
if err = syncOrgAndTenantsFromResponse(ctx, licenseResponse, hadCache); err != nil {
if err = syncOrgAndTenantsFromResponse(ctx, licenseResponse); err != nil {
err = fmt.Errorf("failed to sync organization/tenants from license: %w", err)
return err
}
@@ -381,6 +339,14 @@ func ValidateLicense(ctx context.Context, clearCache bool) (err error) {
}
func fetchValidatedLicense(ctx context.Context) (licenseResponse ValidatedLicense, isCachedResp bool, err error) {
defer func() {
if err != nil {
setLicenseInvalidErr(err)
} else if !isCachedResp {
licenseInvalidErr.Store(nil)
}
}()
licenseKeyValue := servercfg.GetLicenseKey()
netmakerTenantID := servercfg.GetNetmakerTenantID()
slog.Info("proceeding with Netmaker license validation...")
@@ -430,6 +396,11 @@ func fetchValidatedLicense(ctx context.Context) (licenseResponse ValidatedLicens
validationResponse, apiErr := callLicenseValidationApi(ctx, encryptedData, tempPubKey)
if apiErr != nil {
slog.Warn("failed to validate license key, falling back to cached response", "error", apiErr)
var rejected *licenseRejectedError
if errors.As(apiErr, &rejected) {
// a rejected license stays invalid even if a cached response exists.
setLicenseInvalidErr(rejected)
}
validationResponse, err = getCachedResponseFromDB(ctx)
if err != nil {
return licenseResponse, false, fmt.Errorf("failed to validate license key: %w", apiErr)
@@ -483,8 +454,21 @@ type licenseErrorResponse struct {
Message string `json:"message"`
}
// licenseRejectedError is returned when the validation backend rejects the license.
type licenseRejectedError struct {
message string
}
func (e *licenseRejectedError) Error() string {
return e.message
}
var licenseInvalidErr atomic.Pointer[error]
func setLicenseInvalidErr(err error) {
licenseInvalidErr.Store(&err)
}
func callLicenseValidationApi(ctx context.Context, encryptedData []byte, publicKey *[32]byte) ([]byte, error) {
publicKeyBytes, err := ncutils.ConvertKeyToBytes(publicKey)
if err != nil {
@@ -537,14 +521,11 @@ func callLicenseValidationApi(ctx context.Context, encryptedData []byte, publicK
var errResp licenseErrorResponse
jsonErr := json.Unmarshal(body, &errResp)
if jsonErr == nil && errResp.Code == http.StatusUnauthorized {
invalidErr := errors.New(errResp.Message)
licenseInvalidErr.Store(&invalidErr)
return nil, &licenseRejectedError{message: errResp.Message}
}
}
return nil, fmt.Errorf("could not validate license with validation backend (status={%d})", resp.StatusCode)
}
licenseInvalidErr.Store(nil)
return body, nil
}