Skip to content
← Tous les articles
· Delta1 Labs KeyrightLicences.NETAnalyse approfondie

L'état de la licence comme health check : mapper une licence Keyright sur Healthy, Degraded et Unhealthy sans boucle de redémarrage

Un service .NET headless n'a pas de boîte « À propos » où afficher sa licence, si bien que le seul endroit où les opérateurs voient jamais l'état de la licence est le tableau de bord de supervision — ce qui signifie que la licence doit devenir un health check. La version naïve branche la licence directement sur la sonde de readiness : une clé expirée se met à renvoyer 503, l'orchestrateur retire l'instance de la rotation puis la tue, le remplaçant démarre avec la même clé expirée, et vous avez construit une boucle de redémarrage à partir d'un événement de facturation. Cet article lit ce que les trois valeurs de HealthStatus signifient réellement pour un orchestrateur, y mappe le LicenseStatus de Keyright de sorte qu'une clé valide mais proche de l'expiration soit Degraded plutôt qu'Unhealthy, tient l'état de la licence entièrement à l'écart de la sonde de liveness pour qu'une échéance ne déclenche jamais un kill, met la validation en cache pour qu'une sonde par seconde ne revérifie pas une signature à chaque fois, et expose le détail dont les opérateurs ont besoin via le dictionnaire de données du health check. Il se termine par la disposition des tags qui sépare l'endpoint de licence de la readiness et de la liveness, et par le test qui prouve qu'une clé expirée se dégrade sans faire tomber le processus.

Un service headless — un worker, un backend gRPC, un hôte de tâches planifiées — n’a nulle part où vous montrer sa licence. Pas de boîte « À propos », pas de barre d’état, pas d’écran de paramètres. Le seul endroit où un opérateur remarquera un jour que la clé a expiré la nuit dernière est le tableau de bord de supervision, ce qui signifie que la licence doit devenir un health check. Le piège, c’est que le branchement évident — la licence dans la sonde de readiness — transforme un événement de facturation en crash loop. Cet article construit la version qui ne le fait pas.

Il s’agit de mapper une licence Keyright sur le modèle de santé d’ASP.NET Core de sorte que valide soit vert, proche de l’expiration soit orange et continue de servir, et expirée soit rouge et bruyante — sans jamais laisser une licence échue déclencher un redémarrage.

Trois statuts, et ce qu’un orchestrateur fait de chacun

Les health checks d’ASP.NET Core ont exactement trois issues, et toute la conception repose sur l’usage des trois plutôt que des deux vers lesquelles la plupart des gens se tournent :

public enum HealthStatus { Unhealthy = 0, Degraded = 1, Healthy = 2 }

Le middleware de rapport les mappe sur HTTP pour vous : Healthy et Degraded renvoient tous deux 200, et seul Unhealthy renvoie 503. Un orchestrateur ou un répartiteur de charge qui lit l’endpoint de readiness retire une instance de la rotation sur un 503 et l’y laisse sur un 200. Degraded est donc le statut qui permet à un contrôle de dire « quelque chose ne va pas, mais continuez de servir » — ce qui est précisément la forme d’une licence dans sa fenêtre de grâce. Réduisez le modèle à Healthy/Unhealthy et vous êtes contraint de déclarer une fenêtre de grâce soit verte (personne ne voit le compte à rebours s’épuiser), soit rouge (une instance encore en droit de servir est retirée de la rotation). Les deux sont faux ; Degraded est la bonne réponse, et elle est là, à portée de main.

Le mapping

Le Validate() de Keyright renvoie un LicenseInfo dont le Status est un LicenseStatus. Le health check est la table de traduction de cet enum vers HealthStatus :

using Microsoft.Extensions.Diagnostics.HealthChecks;

public sealed class LicenseHealthCheck(LicenseSnapshot snapshot) : IHealthCheck
{
    public Task<HealthCheckResult> CheckHealthAsync(
        HealthCheckContext context, CancellationToken ct = default)
    {
        LicenseInfo info = snapshot.Current;   // cached; see below — no crypto on this path

        var data = new Dictionary<string, object>
        {
            ["status"]        = info.Status.ToString(),
            ["licensee"]      = info.Licensee ?? "(none)",
            ["expiryUtc"]     = info.ExpiryUtc?.ToString("O") ?? "(n/a)",
            ["daysRemaining"] = info.DaysRemaining,
        };

        HealthCheckResult result = info.Status switch
        {
            LicenseStatus.Valid when info.IsExpiringSoon
                => HealthCheckResult.Degraded(
                     $"License valid, expires in {info.DaysRemaining} day(s).", data: data),

            LicenseStatus.Valid
                => HealthCheckResult.Healthy("License valid.", data),

            // Still entitled, serving on a cached lease inside the grace window.
            LicenseStatus.Valid or LicenseStatus.Expired when snapshot.InGraceWindow
                => HealthCheckResult.Degraded(
                     "Running on cached lease inside grace window.", data: data),

            _ => HealthCheckResult.Unhealthy(
                     $"License not valid: {info.Status}. {info.Message}", data: data),
        };

        return Task.FromResult(result);
    }
}

Expired, Revoked, SignatureInvalid, MachineMismatch, ClockTampered et NoLicense tombent tous dans la branche _ et ressortent Unhealthy — le service tourne sans licence et son propriétaire doit le savoir maintenant. Les deux branches Degraded sont tout l’objet de l’exercice : une clé valide mais dans sa fenêtre de renouvellement, et une clé échue mais encore dans une fenêtre de grâce sur un bail mis en cache. Toutes deux maintiennent le service en rotation et toutes deux peignent le tableau de bord en orange.

LicenseStatus → HealthStatus → HTTP → sondeValidValid + IsExpiringSoon(fenêtre de renouvellement)Valid/Expired +InGraceWindowExpired · RevokedSignatureInvalidMachineMismatchClockTamperedNoLicenseHealthy · 200Degraded · 200en rotation, orangeUnhealthy · 503hors rotationQuelle sonde/health/live → jamais de contrôle de licence/health/ready → licence facultative ici/health/license → toujours lu par la supervisionDegraded continue de servir pendantqu'on alerte l'opérateur ; livenessne la porte jamais : un redémarragene peut pas renouveler une clé.

Tenez-la à l’écart de la liveness — la boucle de redémarrage

Voici la défaillance que produit le branchement sur la sonde de readiness. La liveness répond à « ce processus est-il bloqué, faut-il le redémarrer ? ». La readiness répond à « cette instance doit-elle recevoir du trafic ? ». Mettez la licence sur la liveness et la séquence est la suivante :

  1. La clé expire à minuit. Le contrôle de liveness passe à Unhealthy et renvoie 503.
  2. L’orchestrateur tue le conteneur.
  3. Le remplaçant démarre, lit la même licence au même endroit, et le contrôle de liveness repasse à Unhealthy.
  4. Retour à l’étape 2.

Vous avez construit un crash loop dont la cause racine est un événement de facturation, et les redémarrages enterrent la seule ligne de log qui vous l’aurait dit. Un redémarrage peut réparer un processus bloqué ; il ne peut pas renouveler une licence. Le contrôle de licence va donc sur la readiness au plus, de préférence sur son propre endpoint, et jamais sur la liveness. Enregistrez les contrôles avec des tags et séparez les endpoints par tag :

builder.Services
    .AddSingleton<LicenseSnapshot>()                          // the cached snapshot (next section)
    .AddHealthChecks()
    .AddCheck<LicenseHealthCheck>("license", tags: ["license"])
    .AddCheck("self", () => HealthCheckResult.Healthy(), tags: ["live", "ready"]);

var app = builder.Build();

// Liveness: nothing that a restart cannot fix. No license here.
app.MapHealthChecks("/health/live", new HealthCheckOptions
{
    Predicate = r => r.Tags.Contains("live"),
});

// Readiness: the license may ride along, but a Degraded grace window stays 200.
app.MapHealthChecks("/health/ready", new HealthCheckOptions
{
    Predicate = r => r.Tags.Contains("ready"),
});

// A dedicated endpoint monitoring scrapes for the amber/red license signal.
app.MapHealthChecks("/health/license", new HealthCheckOptions
{
    Predicate      = r => r.Tags.Contains("license"),
    ResponseWriter = WriteLicenseJson,          // emit the data bag, below
});

Si votre plateforme traite la readiness strictement comme 200 ou dehors, laissez "license" hors du prédicat ready comme illustré et laissez seul /health/license le porter, pour qu’une fenêtre de grâce — qui est Degraded/200 — ne puisse jamais être ce qui retire une instance encore en droit de servir. C’est vous qui décidez ce que signifie un trafic dégradé ; vous ne laissez pas une sonde en décider en tuant la machine.

L’instantané : ne revérifiez pas la cryptographie à chaque sonde

Une sonde de readiness peut se déclencher chaque seconde. L’état de la licence change au plus une fois par jour. Valider une signature à chaque sonde est du travail gaspillé et, pire, vous tente de placer un appel réseau sur le chemin de la sonde. Enveloppez le client dans un instantané mémoïsé qui se rafraîchit à une cadence lente, pour que le health check lise un champ :

public sealed class LicenseSnapshot
{
    static readonly KeyrightClient License = KeyrightClient.Initialize(new KeyrightOptions
    {
        Product         = "acme-worker",
        PublicKeyBase64 = AcmeKeys.LicensePublicKey,          // public key only; safe to ship
        ServiceUrl      = "https://licensing.acme.example",
    });

    readonly object _gate = new();
    LicenseInfo _info = License.Validate();
    DateTime _asOf = DateTime.UtcNow;

    public LicenseInfo Current
    {
        get
        {
            lock (_gate)
            {
                if (DateTime.UtcNow - _asOf > TimeSpan.FromSeconds(60))
                {
                    _info = License.Validate();               // crypto + any phone-home here, off the probe
                    _asOf = DateTime.UtcNow;
                }
                return _info;
            }
        }
    }

    public bool InGraceWindow => _info.Status == LicenseStatus.Valid && _info.IsExpiringSoon
                              || (_info.Status == LicenseStatus.Expired && _info.DaysRemaining > -7);
}

Désormais CheckHealthAsync est une lecture en temps constant de _info, la sonde ne bloque jamais sur le réseau, et le rafraîchissement une fois par minute est l’endroit où se produit un renouvellement de bail ou un phone-home en arrière-plan — hors du chemin de la sonde, ce qui est l’autre raison pour laquelle le contrôle ne doit jamais se trouver sur la liveness. (KeyrightClient met déjà en cache un résultat vérifié en interne ; l’instantané ajoute la cadence de rafraîchissement et l’endroit unique où le lire.)

Ce que les opérateurs lisent réellement

Le dictionnaire data de chaque HealthCheckResult est la charge utile qui rend tout cela utile plutôt qu’un simple point coloré. Émettez-le en JSON depuis l’endpoint de licence :

static Task WriteLicenseJson(HttpContext ctx, HealthReport report)
{
    ctx.Response.ContentType = "application/json";
    var entry = report.Entries["license"];
    var payload = new
    {
        status = entry.Status.ToString(),                     // Healthy | Degraded | Unhealthy
        detail = entry.Description,
        data   = entry.Data,                                  // licensee, expiryUtc, daysRemaining
    };
    return ctx.Response.WriteAsJsonAsync(payload);
}

Collectez daysRemaining dans vos métriques et vous obtenez une alerte qui se déclenche trois semaines avant qu’une clé perpétuelle avec maintenance n’arrive à échéance, et non trois semaines après. Conservez les champs licensee et status ; ne mettez pas la clé de licence brute, la signature, ni rien de ce qu’un rappel de renouvellement transporterait dans un endpoint qui peut être moins protégé que l’application elle-même.

Testez la fenêtre de grâce, pas seulement valide et expirée

Le test qui compte est celui que la conception à sonde de readiness aurait raté : une clé expirée mais en période de grâce doit revenir Degraded et continuer de servir, et une clé définitivement expirée doit revenir Unhealthy sans que l’endpoint de liveness ne s’en aperçoive jamais.

[Fact]
public async Task Expiring_key_is_degraded_and_stays_in_rotation()
{
    var snapshot = FakeSnapshot.With(status: LicenseStatus.Valid,
                                     expiryUtc: DateTime.UtcNow.AddDays(5));   // IsExpiringSoon
    var check  = new LicenseHealthCheck(snapshot);

    var result = await check.CheckHealthAsync(new HealthCheckContext());

    Assert.Equal(HealthStatus.Degraded, result.Status);     // amber, not red
    // Degraded is HTTP 200 → the instance is NOT pulled from rotation.
}

[Fact]
public async Task Revoked_key_is_unhealthy()
{
    var snapshot = FakeSnapshot.With(status: LicenseStatus.Revoked);
    var result   = await new LicenseHealthCheck(snapshot)
                         .CheckHealthAsync(new HealthCheckContext());

    Assert.Equal(HealthStatus.Unhealthy, result.Status);    // 503 on the license endpoint
}

Associez-les à une assertion vérifiant que le prédicat "live" ne résout jamais le contrôle "license", afin qu’une refactorisation future ne puisse pas déplacer discrètement la licence sur la liveness et reconstruire le crash loop. Faites tourner l’ensemble contre un service d’émission factice si vous voulez aussi couvrir l’aller-retour du rafraîchissement.

À retenir

La licence d’un service headless n’est jamais visible qu’au tableau de bord, elle doit donc être un health check — mais les trois niveaux de HealthStatus existent pour une raison, et l’état de la licence les utilise tous. Valid est Healthy et 200 ; valide-mais-proche-de-l’expiration ou échue-dans-la-grâce est Degraded et toujours 200, si bien que l’instance continue de servir pendant que le tableau de bord passe à l’orange ; tout le reste est Unhealthy et 503. Tenez le contrôle à l’écart de la liveness pour qu’un événement de facturation ne puisse jamais déclencher une boucle de redémarrage, lisez-le depuis un instantané mémoïsé pour qu’une sonde par seconde ne relance jamais de cryptographie, et émettez daysRemaining pour que l’alerte se déclenche avant l’échéance de la clé, pas après. La licence cesse d’être une bombe à retardement invisible dans un service sans UI et devient une ligne colorée de plus que les ops savent déjà surveiller.

Essayez Nebula.NET

Renforcez votre code .NET en quelques minutes — commencez avec l'édition gratuite.