526 lines
21 KiB
Go
526 lines
21 KiB
Go
package inventory
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"slices"
|
|
"sort"
|
|
"strings"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
|
|
"github.com/novox/mesh-controller/internal/catalogue"
|
|
"github.com/novox/mesh-controller/internal/secrets"
|
|
)
|
|
|
|
// Where sealed secrets live.
|
|
//
|
|
// The table holds nothing usable — see the migration and internal/secrets for why that is the
|
|
// design rather than an inconvenience.
|
|
|
|
// Secret is one provision's credential, sealed to each end.
|
|
type Secret struct {
|
|
Name string
|
|
Consumer string
|
|
// ConsumerModule is which module on that machine it is for.
|
|
//
|
|
// **Part of the key, not a label** (novox/hq 04-ISSUES/022). Two modules on one node wanting
|
|
// the same provision are two consumers, and were one credential until this.
|
|
ConsumerModule string
|
|
// Local is the name the credential goes by inside the consumer where it keeps several for one
|
|
// provision (novox/hq ADR 0094); empty for the ordinary one. Part of the key.
|
|
Local string
|
|
Provider string
|
|
ForConsumer string
|
|
ForProvider string
|
|
ConsumerKey string
|
|
ProviderKey string
|
|
// Origin is `made` — the mesh generated it — or `accepted` — a person supplied it, for
|
|
// something outside the mesh, and the mesh cannot make another (novox/hq 04-ISSUES/070).
|
|
Origin string
|
|
}
|
|
|
|
// Where a pair credential came from.
|
|
const (
|
|
OriginMade = "made"
|
|
OriginAccepted = "accepted"
|
|
)
|
|
|
|
// SecretFor is the credential one module uses for one provision, making it the first time.
|
|
//
|
|
// **Made once and kept**, rather than regenerated whenever it is asked for. A secret that changed
|
|
// on every declaration would restart both ends on every push and would mean the password a
|
|
// provider was told to create never matches the one a consumer was given — which is a mesh that
|
|
// reports success and cannot connect.
|
|
//
|
|
// **Remade when either end's sealing key changes.** A node that rejoined generated a new key and
|
|
// can no longer open what was sealed to the old one, so keeping the blob would deliver something
|
|
// unreadable for ever. The new secret reaches both ends in the same push, which is the only
|
|
// moment they can be changed together.
|
|
func (i *Inventory) SecretFor(ctx context.Context, name, consumer, consumerModule, provider, local string) (
|
|
Secret, error) {
|
|
consumerKey, err := i.SealingKeyOf(ctx, consumer)
|
|
if err != nil {
|
|
return Secret{}, err
|
|
}
|
|
providerKey, err := i.SealingKeyOf(ctx, provider)
|
|
if err != nil {
|
|
return Secret{}, err
|
|
}
|
|
|
|
consumerNode, err := i.NodeByName(ctx, consumer)
|
|
if err != nil {
|
|
return Secret{}, err
|
|
}
|
|
providerNode, err := i.NodeByName(ctx, provider)
|
|
if err != nil {
|
|
return Secret{}, err
|
|
}
|
|
|
|
var held Secret
|
|
err = i.store.Pool().QueryRow(ctx,
|
|
`select for_consumer, for_provider, consumer_key, provider_key, origin from secret
|
|
where name = $1 and consumer = $2 and consumer_module = $3 and provider = $4 and local = $5`,
|
|
name, consumerNode.ID, consumerModule, providerNode.ID, local).
|
|
Scan(&held.ForConsumer, &held.ForProvider, &held.ConsumerKey, &held.ProviderKey, &held.Origin)
|
|
if err == nil && held.ConsumerKey == consumerKey && held.ProviderKey == providerKey {
|
|
held.Name, held.Consumer, held.Provider = name, consumer, provider
|
|
held.ConsumerModule, held.Local = consumerModule, local
|
|
return held, nil
|
|
}
|
|
if err == nil && held.Origin == OriginAccepted {
|
|
// A person supplied this, and the mesh does not hold the value: it cannot seal it to the
|
|
// new key. Refused aloud rather than replaced by something the mesh made up, which would
|
|
// be delivered, reported as applied, and fail to authenticate somewhere else entirely
|
|
// (novox/hq 04-ISSUES/070).
|
|
return Secret{}, fmt.Errorf(
|
|
"%s's %q credential from %s was accepted from a person, and a sealing key at one end "+
|
|
"has changed since. The mesh cannot re-seal a value it does not hold: accept it "+
|
|
"again with `secret accept %s %s %s --provider %s%s`",
|
|
consumerModule, name, provider, consumer, consumerModule, name, provider, localFlag(local))
|
|
}
|
|
|
|
// And to the operator, when the mesh has one (novox/hq ADR 0085, amended): the third copy that
|
|
// makes a vault-provided secret recoverable, and nothing the mesh can open.
|
|
operator, err := i.OperatorKey(ctx)
|
|
if err != nil {
|
|
return Secret{}, err
|
|
}
|
|
made, blob, err := secrets.MakeWithOperator(consumerKey, providerKey, operator)
|
|
if err != nil {
|
|
return Secret{}, err
|
|
}
|
|
forOperator, operatorKey := operatorColumns(operator, blob)
|
|
_, err = i.store.Pool().Exec(ctx,
|
|
`insert into secret (name, consumer, consumer_module, provider, for_consumer, for_provider,
|
|
consumer_key, provider_key, operator_sealed, operator_key, local)
|
|
values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
|
on conflict (name, local, consumer, consumer_module, provider) do update set
|
|
for_consumer = excluded.for_consumer, for_provider = excluded.for_provider,
|
|
consumer_key = excluded.consumer_key, provider_key = excluded.provider_key,
|
|
created_at = now(),
|
|
operator_sealed = excluded.operator_sealed, operator_key = excluded.operator_key`,
|
|
name, consumerNode.ID, consumerModule, providerNode.ID,
|
|
made.ForConsumer, made.ForProvider, made.ConsumerKey, made.ProviderKey, forOperator, operatorKey, local)
|
|
if err != nil {
|
|
return Secret{}, err
|
|
}
|
|
return Secret{Name: name, Consumer: consumer, ConsumerModule: consumerModule, Local: local,
|
|
Provider: provider,
|
|
ForConsumer: made.ForConsumer, ForProvider: made.ForProvider,
|
|
ConsumerKey: made.ConsumerKey, ProviderKey: made.ProviderKey, Origin: OriginMade}, nil
|
|
}
|
|
|
|
// AcceptSecretForPair takes a value a person supplied into a pair credential — sealed to the
|
|
// consumer's node and to the provider's, and to the operator when the mesh has one — where the
|
|
// mesh would otherwise have made one (novox/hq 04-ISSUES/070, ADR 0092).
|
|
//
|
|
// This is the vault's third species: a credential for something outside the mesh, which only a
|
|
// person can supply. It is the counterpart to AcceptSecretForModule for a module's own secret;
|
|
// what differs is that both ends of the pair are sealed to, and that the record says `accepted`
|
|
// so a later read never replaces it with a minted one. The plaintext is discarded here.
|
|
func (i *Inventory) AcceptSecretForPair(ctx context.Context, name, consumer, consumerModule, provider, local, value string) error {
|
|
// Refused for a requirement the module does not have, or a local it does not keep under it
|
|
// (novox/hq 04-ISSUES/078): the credential would sit in the pair unread.
|
|
m, err := i.declared(ctx, consumerModule)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !slices.Contains(m.Requires, name) {
|
|
return fmt.Errorf("%s does not require %q; it requires: %s", consumerModule, name, orNone(m.Requires))
|
|
}
|
|
if locals := m.SecretsMany[name]; len(locals) > 0 {
|
|
if local == "" {
|
|
return fmt.Errorf("%s keeps several secrets for %q; name one with --local: %s",
|
|
consumerModule, name, orNone(sortedNames(locals)))
|
|
}
|
|
if _, kept := locals[local]; !kept {
|
|
return fmt.Errorf("%s does not keep %q for %q; it keeps: %s",
|
|
consumerModule, local, name, orNone(sortedNames(locals)))
|
|
}
|
|
} else if m.Secrets[name] == "" {
|
|
return fmt.Errorf("%s requires %q but keeps no secret for it, so a delivered value would sit unread; "+
|
|
"it keeps secrets for: %s", consumerModule, name, orNone(sortedNames(m.Secrets)))
|
|
} else if local != "" {
|
|
return fmt.Errorf("%s keeps one secret for %q, not several; drop --local", consumerModule, name)
|
|
}
|
|
consumerKey, err := i.SealingKeyOf(ctx, consumer)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
providerKey, err := i.SealingKeyOf(ctx, provider)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if consumerKey == "" || providerKey == "" {
|
|
return fmt.Errorf(
|
|
"both %s and %s need a sealing key before a credential can be sealed to them — a "+
|
|
"node joins to get one", consumer, provider)
|
|
}
|
|
consumerNode, err := i.NodeByName(ctx, consumer)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
providerNode, err := i.NodeByName(ctx, provider)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
sealed, err := secrets.Accept(value, consumerKey, providerKey)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
forOperator, operatorKey, err := i.operatorSeal(ctx, value)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, err = i.store.Pool().Exec(ctx,
|
|
`insert into secret (name, consumer, consumer_module, provider, for_consumer, for_provider,
|
|
consumer_key, provider_key, operator_sealed, operator_key, origin, local)
|
|
values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
|
on conflict (name, local, consumer, consumer_module, provider) do update set
|
|
for_consumer = excluded.for_consumer, for_provider = excluded.for_provider,
|
|
consumer_key = excluded.consumer_key, provider_key = excluded.provider_key,
|
|
created_at = now(), origin = excluded.origin,
|
|
operator_sealed = excluded.operator_sealed, operator_key = excluded.operator_key`,
|
|
name, consumerNode.ID, consumerModule, providerNode.ID,
|
|
sealed.ForConsumer, sealed.ForProvider, sealed.ConsumerKey, sealed.ProviderKey,
|
|
forOperator, operatorKey, OriginAccepted, local)
|
|
return err
|
|
}
|
|
|
|
// RotateSecret discards what was there, so the next declaration carries a new one.
|
|
//
|
|
// Only a delete. Nothing reads the old value first, because nothing can — and making the
|
|
// replacement here rather than on the next read would be a second path to the same act, which is
|
|
// how two ends come to hold different passwords.
|
|
//
|
|
// The new secret then reaches both ends on the same push, together, which is what makes rotation
|
|
// a single event rather than a fanout with a window where half the mesh holds a dead credential.
|
|
//
|
|
// **An accepted credential is not rotated.** The mesh did not make it and cannot make its
|
|
// replacement; deleting it would have the next read mint one, which is exactly the wrong value
|
|
// delivered with the mesh insisting it was (novox/hq 04-ISSUES/070). Refused, and the remedy named.
|
|
func (i *Inventory) RotateSecret(ctx context.Context, name, consumer, consumerModule, provider, local string) error {
|
|
consumerNode, err := i.NodeByName(ctx, consumer)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
providerNode, err := i.NodeByName(ctx, provider)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var origin string
|
|
err = i.store.Pool().QueryRow(ctx,
|
|
`select origin from secret where name = $1 and consumer = $2 and consumer_module = $3
|
|
and provider = $4 and local = $5`,
|
|
name, consumerNode.ID, consumerModule, providerNode.ID, local).Scan(&origin)
|
|
if err == nil && origin == OriginAccepted {
|
|
return fmt.Errorf(
|
|
"%s's %q credential from %s was accepted from a person, and the mesh cannot make "+
|
|
"its replacement. Accept the new value instead: `secret accept %s %s %s "+
|
|
"--provider %s%s --from <file>`",
|
|
consumerModule, name, provider, consumer, consumerModule, name, provider, localFlag(local))
|
|
}
|
|
_, err = i.store.Pool().Exec(ctx,
|
|
`delete from secret where name = $1 and consumer = $2 and consumer_module = $3
|
|
and provider = $4 and local = $5`,
|
|
name, consumerNode.ID, consumerModule, providerNode.ID, local)
|
|
return err
|
|
}
|
|
|
|
// SecretsFrom is every credential a provider node was issued, so it can be told what to create.
|
|
func (i *Inventory) SecretsFrom(ctx context.Context, provider string) ([]Secret, error) {
|
|
providerNode, err := i.NodeByName(ctx, provider)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
rows, err := i.store.Pool().Query(ctx,
|
|
`select s.name, c.name, s.consumer_module, s.local, s.for_provider from secret s
|
|
join node c on c.id = s.consumer
|
|
where s.provider = $1 order by s.name, c.name, s.consumer_module, s.local`, providerNode.ID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var out []Secret
|
|
for rows.Next() {
|
|
s := Secret{Provider: provider}
|
|
if err := rows.Scan(&s.Name, &s.Consumer, &s.ConsumerModule, &s.Local, &s.ForProvider); err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, s)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// SecretForModule is a secret a module needs in order to be itself, on one machine.
|
|
//
|
|
// Not the credential a consumer is given: a superuser password is not *for* anybody. Made once
|
|
// and kept, because regenerating it on every declaration would change the password a running
|
|
// database has already been started with — and remade when the node's sealing key changes, for
|
|
// the same reason as everything else sealed here.
|
|
// ModuleSecretIfIssued is what a module already holds on a node, and nothing if it holds nothing.
|
|
//
|
|
// **The read half of SecretForModule**, which mints one when there is none — an insert, and a row
|
|
// lock, on a path that also serves questions. Composing a declaration to answer *is this machine
|
|
// running what I would send it* went through the minting version for every module on every node,
|
|
// so asking wrote to the database and blocked against the machine it was asking about.
|
|
//
|
|
// A module with no secret yet has never been sent one, which is the same answer the caller wanted
|
|
// anyway: this machine is not running what the mesh would send it.
|
|
func (i *Inventory) ModuleSecretIfIssued(
|
|
ctx context.Context, node, module, name string,
|
|
) (string, bool, error) {
|
|
key, err := i.SealingKeyOf(ctx, node)
|
|
if err != nil || key == "" {
|
|
return "", false, err
|
|
}
|
|
record, err := i.NodeByName(ctx, node)
|
|
if err != nil {
|
|
return "", false, err
|
|
}
|
|
var sealed, against, origin string
|
|
err = i.store.Pool().QueryRow(ctx,
|
|
`select sealed, node_key, origin from module_secret
|
|
where node = $1 and module = $2 and name = $3`,
|
|
record.ID, module, name).Scan(&sealed, &against, &origin)
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return "", false, nil
|
|
}
|
|
if err != nil {
|
|
return "", false, err
|
|
}
|
|
// Sealed to a key the node no longer has is not something it holds. Reported as absent rather
|
|
// than as an error: this is the read, and refusing here would make a question fail for a
|
|
// condition its writing counterpart is the right place to explain.
|
|
if against != key {
|
|
return "", false, nil
|
|
}
|
|
return sealed, true, nil
|
|
}
|
|
|
|
func (i *Inventory) SecretForModule(ctx context.Context, node, module, name string) (string, error) {
|
|
key, err := i.SealingKeyOf(ctx, node)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if key == "" {
|
|
return "", fmt.Errorf(
|
|
"%s needs a secret and %s has no sealing key, so nothing can be sealed to it",
|
|
module, node)
|
|
}
|
|
record, err := i.NodeByName(ctx, node)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
var sealed, against, origin string
|
|
err = i.store.Pool().QueryRow(ctx,
|
|
`select sealed, node_key, origin from module_secret
|
|
where node = $1 and module = $2 and name = $3`,
|
|
record.ID, module, name).Scan(&sealed, &against, &origin)
|
|
if err == nil && against == key {
|
|
return sealed, nil
|
|
}
|
|
if err == nil && origin == "accepted" {
|
|
// Sealed to a key this node no longer has, and not the mesh's to invent again. Making one
|
|
// would put 32 random bytes where a working credential was: the machine would apply it,
|
|
// report success, and whatever reads it would fail to authenticate somewhere else
|
|
// entirely — with the mesh insisting the secret was delivered, which it was.
|
|
return "", fmt.Errorf(
|
|
"%s on %s holds %q, which was given to the mesh rather than made by it, and %s has "+
|
|
"since generated a new sealing key. The mesh cannot make another; issue it again",
|
|
module, node, name, node)
|
|
}
|
|
|
|
operator, err := i.OperatorKey(ctx)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
// Sealed once to the machine — Make seals to two ends because a provision has two; here both
|
|
// are the same machine, and only one copy is kept — and once more to the operator when the
|
|
// mesh has one (novox/hq ADR 0085, amended), which is the copy a person can recover from.
|
|
made, blob, err := secrets.MakeWithOperator(key, key, operator)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
forOperator, operatorKey := operatorColumns(operator, blob)
|
|
if _, err := i.store.Pool().Exec(ctx,
|
|
`insert into module_secret (node, module, name, sealed, node_key, origin, operator_sealed, operator_key)
|
|
values ($1, $2, $3, $4, $5, 'made', $6, $7)
|
|
on conflict (node, module, name) do update set
|
|
sealed = excluded.sealed, node_key = excluded.node_key,
|
|
origin = excluded.origin, made_at = now(),
|
|
operator_sealed = excluded.operator_sealed, operator_key = excluded.operator_key`,
|
|
record.ID, module, name, made.ForConsumer, key, forOperator, operatorKey); err != nil {
|
|
return "", err
|
|
}
|
|
return made.ForConsumer, nil
|
|
}
|
|
|
|
// AcceptSecretForModule keeps a value somebody supplied as a module's own secret.
|
|
//
|
|
// The counterpart to SecretForModule, which generates one. Some of what a module needs the mesh
|
|
// cannot invent: a broker account exists because the broker was told about it, and the password is
|
|
// whatever was agreed with the broker at that moment. The mesh's job is to carry it to the machine
|
|
// that will use it without being able to read it afterwards.
|
|
//
|
|
// Sealed on the way in and the plaintext discarded, exactly as a generated one is — so the only
|
|
// difference between the two is where the value came from.
|
|
func (i *Inventory) AcceptSecretForModule(ctx context.Context, node, module, name, value string) error {
|
|
// Refused for a name the module does not declare. A value stored under a name nothing reads
|
|
// is a delivery that changed nothing and reported success — the shape of failure the mesh
|
|
// is built to refuse (novox/hq 04-ISSUES/078).
|
|
m, err := i.declared(ctx, module)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if _, own := m.OwnSecrets[name]; !own {
|
|
return fmt.Errorf("%s does not declare %q as an own secret; %s", module, name, declaresOwn(m))
|
|
}
|
|
key, err := i.SealingKeyOf(ctx, node)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if key == "" {
|
|
return fmt.Errorf(
|
|
"%s has no sealing key, so nothing can be sealed to it — it joins again to get one",
|
|
node)
|
|
}
|
|
record, err := i.NodeByName(ctx, node)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
sealed, err := secrets.Accept(value, key, key)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
// And to the operator, when the mesh has one: a value a person supplied is the one a person
|
|
// most needs to get back, since the mesh cannot make another (novox/hq ADR 0085, amended).
|
|
forOperator, operatorKey, err := i.operatorSeal(ctx, value)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, err = i.store.Pool().Exec(ctx,
|
|
`insert into module_secret (node, module, name, sealed, node_key, origin, operator_sealed, operator_key)
|
|
values ($1, $2, $3, $4, $5, 'accepted', $6, $7)
|
|
on conflict (node, module, name) do update set
|
|
sealed = excluded.sealed, node_key = excluded.node_key,
|
|
origin = excluded.origin, made_at = now(),
|
|
operator_sealed = excluded.operator_sealed, operator_key = excluded.operator_key`,
|
|
record.ID, module, name, sealed.ForConsumer, key, forOperator, operatorKey)
|
|
return err
|
|
}
|
|
|
|
// Holder is one end-to-end credential: who gets it and who must create it.
|
|
type Holder struct {
|
|
Provision string
|
|
Consumer string
|
|
// ConsumerModule is which module on that machine holds it. Part of what identifies a
|
|
// credential (novox/hq 04-ISSUES/022), so rotating one consumer's does not touch another's.
|
|
ConsumerModule string
|
|
// Local is the credential's name inside the consumer where it holds several (ADR 0094).
|
|
Local string
|
|
Provider string
|
|
}
|
|
|
|
// HoldersOf is every pair sharing a credential for one provision.
|
|
//
|
|
// **The question rotation has to ask, and the one HAL could not.** There, a provision had a single
|
|
// shared credential and rotating it updated the provider's row; nothing enumerated who else held
|
|
// the old one, so three nodes carried dead credentials for two days and the mesh reported success
|
|
// (novox/hq ADR 0001). Here each pair has its own credential, and this is the list that makes
|
|
// "every consumer" a set the mesh can name rather than a hope.
|
|
//
|
|
// Empty consumer means all of them.
|
|
func (i *Inventory) HoldersOf(ctx context.Context, provision, consumer string) ([]Holder, error) {
|
|
rows, err := i.store.Pool().Query(ctx,
|
|
`select s.name, c.name, s.consumer_module, s.local, p.name from secret s
|
|
join node c on c.id = s.consumer
|
|
join node p on p.id = s.provider
|
|
where s.name = $1 and ($2 = '' or c.name = $2)
|
|
order by c.name, s.consumer_module, s.local, p.name`, provision, consumer)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var out []Holder
|
|
for rows.Next() {
|
|
var h Holder
|
|
if err := rows.Scan(&h.Provision, &h.Consumer, &h.ConsumerModule, &h.Local, &h.Provider); err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, h)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// localFlag is the `--local` a remedy has to name where a credential has a local name.
|
|
func localFlag(local string) string {
|
|
if local == "" {
|
|
return ""
|
|
}
|
|
return " --local " + local
|
|
}
|
|
|
|
// declared is the manifest the mesh holds for a module — what a delivered value is checked
|
|
// against, so a delivery for a name the module does not have is refused rather than stored.
|
|
func (i *Inventory) declared(ctx context.Context, module string) (catalogue.Manifest, error) {
|
|
known, err := i.Catalogue(ctx)
|
|
if err != nil {
|
|
return catalogue.Manifest{}, err
|
|
}
|
|
m, ok := known[module]
|
|
if !ok {
|
|
return catalogue.Manifest{}, fmt.Errorf("%s is not a module the mesh knows; `module add` it first", module)
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
func declaresOwn(m catalogue.Manifest) string {
|
|
if len(m.OwnSecrets) == 0 {
|
|
return "it declares no own secrets"
|
|
}
|
|
return "it declares: " + strings.Join(sortedNames(m.OwnSecrets), ", ")
|
|
}
|
|
|
|
func sortedNames(of map[string]string) []string {
|
|
names := make([]string, 0, len(of))
|
|
for name := range of {
|
|
names = append(names, name)
|
|
}
|
|
sort.Strings(names)
|
|
return names
|
|
}
|
|
|
|
func orNone(names []string) string {
|
|
if len(names) == 0 {
|
|
return "none"
|
|
}
|
|
return strings.Join(names, ", ")
|
|
}
|