feat: l for details; details for vaults, scopes and files; getting started; the age of cached lists (#17)
- `l` goes to the details of what is selected; `enter` still drills in - vaults, scopes and files show their details (vault settings from the listing, no extra requests) - with nothing to show, the right pane says how to get started - a list from the cache says how old it is while it reloads - what is expiring, in the status pane and `lazysecrets expiring` - when access is refused, what to check, for Azure and Databricks - no group list in an account's details, and no Microsoft Graph call for it Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit was merged in pull request #17.
This commit is contained in:
1 parent
9b0329e9da
commit
09bacd8438
32 files changed
+1321
-254
No files matched your search
@@ -1,15 +1,10 @@
|
||||
package azure
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/Azure/azure-sdk-for-go/sdk/azcore/policy"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||
)
|
||||
|
||||
@@ -160,68 +155,3 @@ func directoryRoles(wids any) string {
|
||||
slices.Sort(names)
|
||||
return strings.Join(names, ", ")
|
||||
}
|
||||
|
||||
// graphGroups lists the signed-in user's groups, nested ones too, from
|
||||
// Microsoft Graph with az's token for it.
|
||||
const graphGroups = "https://graph.microsoft.com/v1.0/me/transitiveMemberOf?$select=displayName&$top=999"
|
||||
|
||||
var _ provider.AccountGroups = (*Provider)(nil)
|
||||
|
||||
// Groups lists the groups the account is a member of, by name.
|
||||
func (p *Provider) Groups(ctx context.Context) ([]string, error) {
|
||||
if p.account == nil || (p.account.cloud != "" && p.account.cloud != "AzureCloud") {
|
||||
return nil, provider.ErrUnsupported
|
||||
}
|
||||
tok, err := p.cred.GetToken(ctx, policy.TokenRequestOptions{Scopes: []string{"https://graph.microsoft.com/.default"}})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var names []string
|
||||
for next := graphGroups; next != ""; {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, next, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+tok.Token)
|
||||
resp, err := p.http().Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var page struct {
|
||||
Value []struct {
|
||||
Type string `json:"@odata.type"`
|
||||
Name string `json:"displayName"`
|
||||
} `json:"value"`
|
||||
Next string `json:"@odata.nextLink"`
|
||||
Error *struct {
|
||||
Message string `json:"message"`
|
||||
} `json:"error"`
|
||||
}
|
||||
err = json.NewDecoder(resp.Body).Decode(&page)
|
||||
resp.Body.Close()
|
||||
switch {
|
||||
case err != nil:
|
||||
return nil, fmt.Errorf("listing groups from Microsoft Graph: %w", err)
|
||||
case page.Error != nil:
|
||||
return nil, fmt.Errorf("listing groups from Microsoft Graph: %s", page.Error.Message)
|
||||
case resp.StatusCode != http.StatusOK:
|
||||
return nil, fmt.Errorf("listing groups from Microsoft Graph: %s", resp.Status)
|
||||
}
|
||||
for _, v := range page.Value {
|
||||
if v.Type == "#microsoft.graph.group" && v.Name != "" {
|
||||
names = append(names, v.Name)
|
||||
}
|
||||
}
|
||||
next = page.Next
|
||||
}
|
||||
slices.SortFunc(names, func(a, b string) int { return strings.Compare(strings.ToLower(a), strings.ToLower(b)) })
|
||||
return names, nil
|
||||
}
|
||||
|
||||
// http is the client for Microsoft Graph; tests replace it.
|
||||
func (p *Provider) http() *http.Client {
|
||||
if p.graphClient != nil {
|
||||
return p.graphClient
|
||||
}
|
||||
return http.DefaultClient
|
||||
}
|
||||
@@ -1,17 +1,7 @@
|
||||
package azure
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Azure/azure-sdk-for-go/sdk/azcore"
|
||||
"github.com/Azure/azure-sdk-for-go/sdk/azcore/policy"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||
)
|
||||
@@ -75,40 +65,3 @@ func TestSignInMethods(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type staticToken struct{}
|
||||
|
||||
func (staticToken) GetToken(context.Context, policy.TokenRequestOptions) (azcore.AccessToken, error) {
|
||||
return azcore.AccessToken{Token: "tok", ExpiresOn: time.Now().Add(time.Hour)}, nil
|
||||
}
|
||||
|
||||
type graphPages map[string]string // by URL
|
||||
|
||||
func (g graphPages) RoundTrip(r *http.Request) (*http.Response, error) {
|
||||
body, ok := g[r.URL.String()]
|
||||
if !ok || r.Header.Get("Authorization") != "Bearer tok" {
|
||||
return &http.Response{StatusCode: 404, Status: "404 Not Found", Body: io.NopCloser(strings.NewReader(`{}`))}, nil
|
||||
}
|
||||
return &http.Response{StatusCode: 200, Status: "200 OK", Body: io.NopCloser(strings.NewReader(body))}, nil
|
||||
}
|
||||
|
||||
func TestGroups(t *testing.T) {
|
||||
next := "https://graph.microsoft.com/v1.0/next"
|
||||
p := &Provider{
|
||||
cred: staticToken{},
|
||||
account: &accountDetails{account: account{user: "alice@contoso.com"}},
|
||||
graphClient: &http.Client{Transport: graphPages{
|
||||
graphGroups: `{"value":[{"@odata.type":"#microsoft.graph.group","displayName":"vault readers"},
|
||||
{"@odata.type":"#microsoft.graph.directoryRole","displayName":"Global Reader"}],"@odata.nextLink":"` + next + `"}`,
|
||||
next: `{"value":[{"@odata.type":"#microsoft.graph.group","displayName":"Platform team"}]}`,
|
||||
}},
|
||||
}
|
||||
got, err := p.Groups(context.Background())
|
||||
if err != nil || !slices.Equal(got, []string{"Platform team", "vault readers"}) {
|
||||
t.Errorf("groups %q, %v", got, err)
|
||||
}
|
||||
p.account.cloud = "AzureChinaCloud"
|
||||
if _, err := p.Groups(context.Background()); !errors.Is(err, provider.ErrUnsupported) {
|
||||
t.Errorf("other cloud: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"crypto/x509"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
@@ -37,12 +36,11 @@ var objectTypes = []provider.ObjectType{provider.TypeSecret, provider.TypeKey, p
|
||||
type Provider struct {
|
||||
id, label string // "" for the single-account defaults
|
||||
|
||||
cred azcore.TokenCredential
|
||||
opts options
|
||||
account *accountDetails // nil for a service principal from AZURE_*
|
||||
graphClient *http.Client // for Groups; nil for the default
|
||||
subs *armsubscriptions.Client
|
||||
graph *armresourcegraph.Client
|
||||
cred azcore.TokenCredential
|
||||
opts options
|
||||
account *accountDetails // nil for a service principal from AZURE_*
|
||||
subs *armsubscriptions.Client
|
||||
graph *armresourcegraph.Client
|
||||
|
||||
mu sync.Mutex
|
||||
vaults map[string]*vaultClients // by vault URL
|
||||
@@ -185,10 +183,6 @@ func (p *Provider) tenant(subscriptionID string) string {
|
||||
return p.tenants[subscriptionID]
|
||||
}
|
||||
|
||||
const vaultQuery = `resources
|
||||
| where type =~ 'microsoft.keyvault/vaults'
|
||||
| project id, name, uri = tostring(properties.vaultUri)`
|
||||
|
||||
func (p *Provider) graphVaults(ctx context.Context, src provider.Source) ([]provider.Container, error) {
|
||||
var out []provider.Container
|
||||
req := armresourcegraph.QueryRequest{
|
||||
@@ -228,7 +222,7 @@ func graphVault(src provider.Source, row any) (provider.Container, bool) {
|
||||
if id == "" || name == "" || uri == "" {
|
||||
return provider.Container{}, false
|
||||
}
|
||||
return provider.Container{Source: src, ID: id, Name: name, Detail: uri, Types: objectTypes}, true
|
||||
return provider.Container{Source: src, ID: id, Name: name, Detail: uri, Types: objectTypes, Attrs: graphInfo(m).attrs()}, true
|
||||
}
|
||||
|
||||
func (p *Provider) armVaults(ctx context.Context, src provider.Source) ([]provider.Container, error) {
|
||||
@@ -249,7 +243,7 @@ func (p *Provider) armVaults(ctx context.Context, src provider.Source) ([]provid
|
||||
}
|
||||
out = append(out, provider.Container{
|
||||
Source: src, ID: deref(v.ID), Name: deref(v.Name),
|
||||
Detail: *v.Properties.VaultURI, Types: objectTypes,
|
||||
Detail: *v.Properties.VaultURI, Types: objectTypes, Attrs: armInfo(v).attrs(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -174,8 +175,11 @@ func TestForbiddenIsShortened(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if got, want := err.Error(), "403 Forbidden: Caller is not authorized to perform action on resource."; got != want {
|
||||
t.Errorf("error = %q, want %q", got, want)
|
||||
if got, want := err.Error(), "403 Forbidden: Caller is not authorized to perform action on resource.\n\nCheck:\n• your role or group is activated in PIM\n• "; !strings.HasPrefix(got, want) {
|
||||
t.Errorf("error = %q, want %q…", got, want)
|
||||
}
|
||||
if checks := deniedChecks("Client address is not authorized and caller is not a trusted service."); !strings.Contains(checks[0], "network") {
|
||||
t.Errorf("firewall checks = %q", checks)
|
||||
}
|
||||
var re *azcore.ResponseError
|
||||
if !errors.As(err, &re) || re.StatusCode != 403 {
|
||||
@@ -183,6 +187,13 @@ func TestForbiddenIsShortened(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestShortenPolicyDenied(t *testing.T) {
|
||||
msg := "The user, group or application 'appid=04b07795;oid=1234;numgroups=83;iss=https://sts.windows.net/t/' does not have secrets list permission on key vault 'kv-app;location=norwayeast'. For help resolving this issue, please see https://go.microsoft.com/fwlink/?linkid=2125287"
|
||||
if got := shortenDenied(msg); got != "no secrets list permission in the vault's access policies" {
|
||||
t.Errorf("shortenDenied = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShortenDenied(t *testing.T) {
|
||||
msg := "Caller is not authorized to perform action on resource.\r\nIf role assignments, deny assignments or role definitions were changed recently, please observe propagation time.\r\nCaller: appid=04b07795;oid=1234\r\nAction: 'Microsoft.KeyVault/vaults/secrets/readMetadata/action'\r\nResource: '/subscriptions/x/vaults/kv'"
|
||||
want := "Caller is not authorized to perform action on resource; missing Microsoft.KeyVault/vaults/secrets/readMetadata/action"
|
||||
@@ -417,3 +428,49 @@ func ecPoint(t *testing.T, k *ecdsa.PrivateKey) (x, y []byte) {
|
||||
size := (len(b) - 1) / 2
|
||||
return b[1 : 1+size], b[1+size:]
|
||||
}
|
||||
|
||||
func TestVaultSettings(t *testing.T) {
|
||||
want := []string{"group=rg-sec", "location=norwayeast", "SKU=premium", "access=Azure roles (RBAC)",
|
||||
"soft delete=on, 30 days", "purge prot.=on", "network=selected networks (1 IP ranges, 0 subnets)",
|
||||
"private=1 endpoint", "tags=env: prod", "=team: platform"}
|
||||
check := func(name string, attrs []provider.Attr) {
|
||||
t.Helper()
|
||||
var got []string
|
||||
for _, a := range attrs {
|
||||
got = append(got, a.Label+"="+a.Value)
|
||||
}
|
||||
if !slices.Equal(got, want) {
|
||||
t.Errorf("%s\n got %q\nwant %q", name, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// Resource Graph: the query's columns, as JSON gives them.
|
||||
c, ok := graphVault(provider.Source{ID: "s"}, map[string]any{
|
||||
"id": "/x/kv", "name": "kv", "uri": "https://kv.vault.azure.net/", "location": "norwayeast",
|
||||
"resourceGroup": "rg-sec", "tags": map[string]any{"team": "platform", "env": "prod"},
|
||||
"sku": "premium", "rbac": true, "softDelete": nil, "retention": 30.0, "purge": true,
|
||||
"publicAccess": "", "defaultAction": "Deny", "ipRules": 1.0, "subnets": 0.0, "endpoints": 1.0, "policies": 0.0,
|
||||
})
|
||||
if !ok {
|
||||
t.Fatal("row not read")
|
||||
}
|
||||
check("resource graph", c.Attrs)
|
||||
|
||||
// The management API, when Resource Graph fails.
|
||||
id := "/subscriptions/s/resourceGroups/rg-sec/providers/Microsoft.KeyVault/vaults/kv"
|
||||
p, _ := fakeAzure(t, staticCred{}, func(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, 200, map[string]any{"value": []any{map[string]any{"id": id, "name": "kv", "location": "norwayeast",
|
||||
"tags": map[string]any{"team": "platform", "env": "prod"},
|
||||
"properties": map[string]any{
|
||||
"tenantId": "t", "vaultUri": "https://kv.vault.azure.net/", "sku": map[string]any{"family": "A", "name": "premium"},
|
||||
"enableRbacAuthorization": true, "enablePurgeProtection": true, "softDeleteRetentionInDays": 30,
|
||||
"networkAcls": map[string]any{"defaultAction": "Deny", "ipRules": []any{map[string]any{"value": "10.0.0.0/8"}}},
|
||||
"privateEndpointConnections": []any{map[string]any{"id": "pe1"}},
|
||||
}}}})
|
||||
})
|
||||
vaults, err := p.armVaults(context.Background(), provider.Source{ID: "s"})
|
||||
if err != nil || len(vaults) != 1 {
|
||||
t.Fatalf("arm: %v %v", vaults, err)
|
||||
}
|
||||
check("management API", vaults[0].Attrs)
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"fmt"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/Azure/azure-sdk-for-go/sdk/azcore"
|
||||
@@ -279,13 +280,24 @@ func cleanError(err error) error {
|
||||
}
|
||||
}
|
||||
}
|
||||
return &serviceError{status: re.StatusCode, msg: shortenDenied(msg), err: err}
|
||||
se := &serviceError{status: re.StatusCode, msg: shortenDenied(msg), err: err}
|
||||
if se.status == http.StatusForbidden {
|
||||
return &provider.Denied{Err: se, Checks: deniedChecks(se.msg)}
|
||||
}
|
||||
return se
|
||||
}
|
||||
|
||||
var policyDenied = regexp.MustCompile(`does not have (\w+ \w+) permission on key vault`)
|
||||
|
||||
// shortenDenied reduces Key Vault's RBAC denial, which runs to several
|
||||
// sentences plus caller and resource IDs, to its first sentence and the
|
||||
// missing action.
|
||||
func shortenDenied(msg string) string {
|
||||
// Access policies: "The user, group or application 'appid=…;oid=…'
|
||||
// does not have secrets list permission on key vault 'kv;location=…'".
|
||||
if m := policyDenied.FindStringSubmatch(msg); m != nil {
|
||||
return "no " + m[1] + " permission in the vault's access policies"
|
||||
}
|
||||
const marker = "Action: '"
|
||||
i := strings.Index(msg, marker)
|
||||
if i < 0 {
|
||||
@@ -317,4 +329,20 @@ func (e *serviceError) Error() string {
|
||||
return s
|
||||
}
|
||||
|
||||
// deniedChecks are what to check when Key Vault refuses: the network,
|
||||
// for its firewall's refusals, else access.
|
||||
func deniedChecks(msg string) []string {
|
||||
if strings.Contains(msg, "Client address is not authorized") || strings.Contains(msg, "network access") ||
|
||||
strings.Contains(msg, "private link") {
|
||||
return []string{
|
||||
"you're on a network the vault lets in (VPN, office)",
|
||||
"the vault's owner has let your network in",
|
||||
}
|
||||
}
|
||||
return []string{
|
||||
"your role or group is activated in PIM",
|
||||
"the vault's owner has given you access: a Key Vault role, or its access policies",
|
||||
}
|
||||
}
|
||||
|
||||
func (e *serviceError) Unwrap() error { return e.err }
|
||||
@@ -0,0 +1,158 @@
|
||||
package azure
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/Azure/azure-sdk-for-go/sdk/azcore/arm"
|
||||
"github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/keyvault/armkeyvault"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||
)
|
||||
|
||||
// A vault's settings come with the listing, from Resource Graph or the
|
||||
// Key Vault management API, with no requests of their own: where it is,
|
||||
// how access to it works, and how it's protected.
|
||||
|
||||
type vaultInfo struct {
|
||||
group, location, sku string
|
||||
rbac, softDelete, purge *bool
|
||||
retention *int32
|
||||
publicAccess, defaultAction string
|
||||
ipRules, subnets, endpoints int
|
||||
policies int
|
||||
tags map[string]string
|
||||
}
|
||||
|
||||
// vaultQuery lists vaults with just the settings shown, not their whole
|
||||
// properties (access policies can be long).
|
||||
const vaultQuery = `resources
|
||||
| where type =~ 'microsoft.keyvault/vaults'
|
||||
| project id, name, uri = tostring(properties.vaultUri), location, resourceGroup, tags,
|
||||
sku = tostring(properties.sku.name), rbac = properties.enableRbacAuthorization,
|
||||
softDelete = properties.enableSoftDelete, retention = properties.softDeleteRetentionInDays,
|
||||
purge = properties.enablePurgeProtection, publicAccess = tostring(properties.publicNetworkAccess),
|
||||
defaultAction = tostring(properties.networkAcls.defaultAction),
|
||||
ipRules = array_length(properties.networkAcls.ipRules),
|
||||
subnets = array_length(properties.networkAcls.virtualNetworkRules),
|
||||
endpoints = array_length(properties.privateEndpointConnections),
|
||||
policies = array_length(properties.accessPolicies)`
|
||||
|
||||
// graphInfo reads a vaultQuery row's settings.
|
||||
func graphInfo(m map[string]any) vaultInfo {
|
||||
str := func(k string) string { s, _ := m[k].(string); return s }
|
||||
num := func(k string) int { f, _ := m[k].(float64); return int(f) }
|
||||
flag := func(k string) *bool {
|
||||
if b, ok := m[k].(bool); ok {
|
||||
return &b
|
||||
}
|
||||
return nil
|
||||
}
|
||||
v := vaultInfo{
|
||||
group: str("resourceGroup"), location: str("location"), sku: str("sku"),
|
||||
rbac: flag("rbac"), softDelete: flag("softDelete"), purge: flag("purge"),
|
||||
publicAccess: str("publicAccess"), defaultAction: str("defaultAction"),
|
||||
ipRules: num("ipRules"), subnets: num("subnets"), endpoints: num("endpoints"), policies: num("policies"),
|
||||
}
|
||||
if f, ok := m["retention"].(float64); ok {
|
||||
r := int32(f)
|
||||
v.retention = &r
|
||||
}
|
||||
if tags, ok := m["tags"].(map[string]any); ok {
|
||||
v.tags = map[string]string{}
|
||||
for k, t := range tags {
|
||||
v.tags[k] = fmt.Sprint(t)
|
||||
}
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// armInfo reads a vault's settings from the management API.
|
||||
func armInfo(v *armkeyvault.Vault) vaultInfo {
|
||||
info := vaultInfo{location: deref(v.Location), tags: map[string]string{}}
|
||||
if id, err := arm.ParseResourceID(deref(v.ID)); err == nil {
|
||||
info.group = id.ResourceGroupName
|
||||
}
|
||||
for k, t := range v.Tags {
|
||||
info.tags[k] = deref(t)
|
||||
}
|
||||
pr := v.Properties
|
||||
if pr == nil {
|
||||
return info
|
||||
}
|
||||
if pr.SKU != nil && pr.SKU.Name != nil {
|
||||
info.sku = string(*pr.SKU.Name)
|
||||
}
|
||||
info.rbac, info.softDelete, info.purge = pr.EnableRbacAuthorization, pr.EnableSoftDelete, pr.EnablePurgeProtection
|
||||
info.retention, info.publicAccess = pr.SoftDeleteRetentionInDays, deref(pr.PublicNetworkAccess)
|
||||
if acl := pr.NetworkACLs; acl != nil {
|
||||
if acl.DefaultAction != nil {
|
||||
info.defaultAction = string(*acl.DefaultAction)
|
||||
}
|
||||
info.ipRules, info.subnets = len(acl.IPRules), len(acl.VirtualNetworkRules)
|
||||
}
|
||||
info.endpoints, info.policies = len(pr.PrivateEndpointConnections), len(pr.AccessPolicies)
|
||||
return info
|
||||
}
|
||||
|
||||
func (v vaultInfo) attrs() []provider.Attr {
|
||||
var out []provider.Attr
|
||||
add := func(label, value string) {
|
||||
if value != "" {
|
||||
out = append(out, provider.Attr{Label: label, Value: value})
|
||||
}
|
||||
}
|
||||
add("group", v.group)
|
||||
add("location", v.location)
|
||||
add("SKU", v.sku)
|
||||
// Who may read secrets is decided by Azure roles or by the vault's
|
||||
// own access policies, never both.
|
||||
if on(v.rbac, false) {
|
||||
add("access", "Azure roles (RBAC)")
|
||||
} else {
|
||||
add("access", fmt.Sprintf("access policies (%d)", v.policies))
|
||||
}
|
||||
if on(v.softDelete, true) {
|
||||
days := int32(90)
|
||||
if v.retention != nil {
|
||||
days = *v.retention
|
||||
}
|
||||
add("soft delete", fmt.Sprintf("on, %d days", days))
|
||||
} else {
|
||||
add("soft delete", "off")
|
||||
}
|
||||
add("purge prot.", map[bool]string{true: "on", false: "off"}[on(v.purge, false)])
|
||||
switch {
|
||||
case strings.EqualFold(v.publicAccess, "disabled"):
|
||||
add("network", "private endpoints only")
|
||||
case strings.EqualFold(v.defaultAction, "deny"):
|
||||
add("network", fmt.Sprintf("selected networks (%d IP ranges, %d subnets)", v.ipRules, v.subnets))
|
||||
default:
|
||||
add("network", "all networks")
|
||||
}
|
||||
if v.endpoints > 0 {
|
||||
add("private", fmt.Sprintf("%d endpoint%s", v.endpoints, map[bool]string{true: "s"}[v.endpoints > 1]))
|
||||
}
|
||||
keys := make([]string, 0, len(v.tags))
|
||||
for k := range v.tags {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
slices.Sort(keys)
|
||||
for i, k := range keys {
|
||||
label := ""
|
||||
if i == 0 {
|
||||
label = "tags"
|
||||
}
|
||||
out = append(out, provider.Attr{Label: label, Value: k + ": " + v.tags[k]})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// on is *b, or def when it isn't set.
|
||||
func on(b *bool, def bool) bool {
|
||||
if b == nil {
|
||||
return def
|
||||
}
|
||||
return *b
|
||||
}
|
||||
Reference in new issue
Block a user