diff --git a/.changelog/12138.txt b/.changelog/12138.txt new file mode 100644 index 000000000000..297eedf94484 --- /dev/null +++ b/.changelog/12138.txt @@ -0,0 +1,3 @@ +```release-note:improvement +namespaces: Allow adding custom metadata to namespaces. +``` diff --git a/api/namespace.go b/api/namespace.go index 409a62bdd26d..7e53521263ce 100644 --- a/api/namespace.go +++ b/api/namespace.go @@ -71,6 +71,7 @@ type Namespace struct { Description string Quota string Capabilities *NamespaceCapabilities `hcl:"capabilities,block"` + Meta map[string]string CreateIndex uint64 ModifyIndex uint64 } diff --git a/command/agent/namespace_endpoint_test.go b/command/agent/namespace_endpoint_test.go index 7cbf2301b379..3c5b1bf2f960 100644 --- a/command/agent/namespace_endpoint_test.go +++ b/command/agent/namespace_endpoint_test.go @@ -1,6 +1,3 @@ -//go:build ent -// +build ent - package agent import ( diff --git a/command/namespace_apply.go b/command/namespace_apply.go index 118645963b21..a6acd0da72f9 100644 --- a/command/namespace_apply.go +++ b/command/namespace_apply.go @@ -61,7 +61,11 @@ func (c *NamespaceApplyCommand) AutocompleteFlags() complete.Flags { } func (c *NamespaceApplyCommand) AutocompleteArgs() complete.Predictor { - return NamespacePredictor(c.Meta.Client, nil) + return complete.PredictOr( + NamespacePredictor(c.Meta.Client, nil), + complete.PredictFiles("*.hcl"), + complete.PredictFiles("*.json"), + ) } func (c *NamespaceApplyCommand) Synopsis() string { @@ -216,6 +220,7 @@ func parseNamespaceSpecImpl(result *api.Namespace, list *ast.ObjectList) error { } delete(m, "capabilities") + delete(m, "meta") // Decode the rest if err := mapstructure.WeakDecode(m, result); err != nil { @@ -238,5 +243,17 @@ func parseNamespaceSpecImpl(result *api.Namespace, list *ast.ObjectList) error { } } + if metaO := list.Filter("meta"); len(metaO.Items) > 0 { + for _, o := range metaO.Elem().Items { + var m map[string]interface{} + if err := hcl.DecodeObject(&m, o.Val); err != nil { + return err + } + if err := mapstructure.WeakDecode(m, &result.Meta); err != nil { + return err + } + } + } + return nil } diff --git a/command/namespace_apply_test.go b/command/namespace_apply_test.go index a5611e2addf9..95164b2ba200 100644 --- a/command/namespace_apply_test.go +++ b/command/namespace_apply_test.go @@ -1,6 +1,3 @@ -//go:build ent -// +build ent - package command import ( diff --git a/command/namespace_delete_test.go b/command/namespace_delete_test.go index 155f2cff719e..95fe7c8baf6c 100644 --- a/command/namespace_delete_test.go +++ b/command/namespace_delete_test.go @@ -1,6 +1,3 @@ -//go:build ent -// +build ent - package command import ( diff --git a/command/namespace_inspect_test.go b/command/namespace_inspect_test.go index 296b0fa5d742..35bfef08509d 100644 --- a/command/namespace_inspect_test.go +++ b/command/namespace_inspect_test.go @@ -1,6 +1,3 @@ -//go:build ent -// +build ent - package command import ( diff --git a/command/namespace_list_test.go b/command/namespace_list_test.go index 822199c623cd..b8e662bdd67d 100644 --- a/command/namespace_list_test.go +++ b/command/namespace_list_test.go @@ -1,6 +1,3 @@ -//go:build ent -// +build ent - package command import ( @@ -10,10 +7,7 @@ import ( "github.com/mitchellh/cli" ) -func TestNamespaceListCommand_Implements(t *testing.T) { - t.Parallel() - var _ cli.Command = &NamespaceListCommand{} -} +var _ cli.Command = (*NamespaceListCommand)(nil) func TestNamespaceListCommand_Fails(t *testing.T) { t.Parallel() diff --git a/command/namespace_status.go b/command/namespace_status.go index 4f0f58b7f146..d9d59d76dfa1 100644 --- a/command/namespace_status.go +++ b/command/namespace_status.go @@ -2,6 +2,7 @@ package command import ( "fmt" + "sort" "strings" "github.com/hashicorp/nomad/api" @@ -81,6 +82,16 @@ func (c *NamespaceStatusCommand) Run(args []string) int { c.Ui.Output(formatNamespaceBasics(ns)) + if len(ns.Meta) > 0 { + c.Ui.Output(c.Colorize().Color("\n[bold]Metadata[reset]")) + var meta []string + for k := range ns.Meta { + meta = append(meta, fmt.Sprintf("%s|%s", k, ns.Meta[k])) + } + sort.Strings(meta) + c.Ui.Output(formatKV(meta)) + } + if ns.Quota != "" { quotas := client.Quotas() spec, _, err := quotas.Info(ns.Quota, nil) diff --git a/command/namespace_status_oss_test.go b/command/namespace_status_oss_test.go new file mode 100644 index 000000000000..7bc4d2e56085 --- /dev/null +++ b/command/namespace_status_oss_test.go @@ -0,0 +1,10 @@ +//go:build !ent +// +build !ent + +package command + +import "github.com/hashicorp/nomad/api" + +func testQuotaSpec() *api.QuotaSpec { + panic("not implemented - enterprise only") +} diff --git a/command/namespace_status_test.go b/command/namespace_status_test.go index 74073b8f2711..fa70ed218d02 100644 --- a/command/namespace_status_test.go +++ b/command/namespace_status_test.go @@ -1,6 +1,3 @@ -//go:build ent -// +build ent - package command import ( @@ -77,6 +74,10 @@ func TestNamespaceStatusCommand_Good_Quota(t *testing.T) { srv, client, url := testServer(t, true, nil) defer srv.Shutdown() + if !srv.Enterprise { + t.Skip("Skipping enterprise-only quota test") + } + ui := cli.NewMockUi() cmd := &NamespaceStatusCommand{Meta: Meta{Ui: ui}} diff --git a/nomad/mock/mock.go b/nomad/mock/mock.go index 0b817d5fa658..ef70968ca2c0 100644 --- a/nomad/mock/mock.go +++ b/nomad/mock/mock.go @@ -2246,8 +2246,10 @@ func AllocNetworkStatus() *structs.AllocNetworkStatus { } func Namespace() *structs.Namespace { + uuid := uuid.Generate() ns := &structs.Namespace{ - Name: fmt.Sprintf("team-%s", uuid.Generate()), + Name: fmt.Sprintf("team-%s", uuid), + Meta: map[string]string{"team": uuid}, Description: "test namespace", CreateIndex: 100, ModifyIndex: 200, diff --git a/nomad/structs/structs.go b/nomad/structs/structs.go index 9a30381cf537..78082eb44528 100644 --- a/nomad/structs/structs.go +++ b/nomad/structs/structs.go @@ -4963,6 +4963,9 @@ type Namespace struct { // Capabilities is the set of capabilities allowed for this namespace Capabilities *NamespaceCapabilities + // Meta is the set of metadata key/value pairs that attached to the namespace + Meta map[string]string + // Hash is the hash of the namespace which is used to efficiently replicate // cross-regions. Hash []byte @@ -5016,6 +5019,18 @@ func (n *Namespace) SetHash() []byte { } } + // sort keys to ensure hash stability when meta is stored later + var keys []string + for k := range n.Meta { + keys = append(keys, k) + } + sort.Strings(keys) + + for _, k := range keys { + _, _ = hash.Write([]byte(k)) + _, _ = hash.Write([]byte(n.Meta[k])) + } + // Finalize the hash hashVal := hash.Sum(nil) @@ -5035,6 +5050,12 @@ func (n *Namespace) Copy() *Namespace { c.DisabledTaskDrivers = helper.CopySliceString(n.Capabilities.DisabledTaskDrivers) nc.Capabilities = c } + if n.Meta != nil { + nc.Meta = make(map[string]string, len(n.Meta)) + for k, v := range n.Meta { + nc.Meta[k] = v + } + } copy(nc.Hash, n.Hash) return nc } diff --git a/website/content/api-docs/namespaces.mdx b/website/content/api-docs/namespaces.mdx index 882a279a4b60..33c6363a8927 100644 --- a/website/content/api-docs/namespaces.mdx +++ b/website/content/api-docs/namespaces.mdx @@ -48,17 +48,24 @@ $ curl \ ```json [ { - "CreateIndex": 31, - "Description": "Production API Servers", - "ModifyIndex": 31, - "Name": "api-prod", + "Capabilities": null, + "CreateIndex": 1, + "Description": "Default shared namespace", + "Meta": null, + "ModifyIndex": 1, + "Name": "default", "Quota": "" }, { - "CreateIndex": 5, - "Description": "Default shared namespace", - "ModifyIndex": 5, - "Name": "default", + "Capabilities": null, + "CreateIndex": 17, + "Description": "Development Staging Namespace", + "Meta": { + "type": "dev", + "contact": "helpdesk@example.com" + }, + "ModifyIndex": 17, + "Name": "staging", "Quota": "" } ] @@ -88,19 +95,23 @@ The table below shows this endpoint's support for ```shell-session $ curl \ - https://localhost:4646/v1/namespace/api-prod + https://localhost:4646/v1/namespace/staging ``` ### Sample Response ```json { - "CreateIndex": 31, - "Description": "Production API Servers", - "Quota": "", - "Hash": "N8WvePwqkp6J354eLJMKyhvsFdPELAos0VuBfMoVKoU=", - "ModifyIndex": 31, - "Name": "api-prod" + "Capabilities": null, + "CreateIndex": 17, + "Description": "Development Staging Namespace", + "Meta": { + "type": "dev", + "contact": "helpdesk@example.com" + }, + "ModifyIndex": 17, + "Name": "staging", + "Quota": "" } ``` @@ -128,6 +139,10 @@ The table below shows this endpoint's support for - `Description` `(string: "")` - Specifies an optional human-readable description of the namespace. +- `Meta` `(object: null)` - Optional object with string keys and values of + metadata to attach to the namespace. Namespace metadata is not used by Nomad + and is intended for use by operators and third party tools. + - `Quota` `(string: "")` - Specifies an quota to attach to the namespace. ### Sample Payload @@ -136,10 +151,15 @@ The table below shows this endpoint's support for { "Name": "api-prod", "Description": "Production API Servers", + "Meta": { + "contact": "platform-eng@example.com" + }, "Quota": "prod-quota" } ``` +Note that the `Quota` key is Enterprise-only. + ### Sample Request ```shell-session diff --git a/website/content/docs/commands/namespace/apply.mdx b/website/content/docs/commands/namespace/apply.mdx index d9f93453caa0..3f42e8e8b05f 100644 --- a/website/content/docs/commands/namespace/apply.mdx +++ b/website/content/docs/commands/namespace/apply.mdx @@ -18,7 +18,7 @@ when introduced in Nomad 0.7. nomad namespace apply [options] ``` -Apply is used to create or update a namespace. The specification file +Apply is used to create or update a namespace. The HCL specification file will be read from stdin by specifying "-", otherwise a path to the file is expected. @@ -37,7 +37,7 @@ If ACLs are enabled, this command requires a management ACL token. - `-description` : An optional human readable description for the namespace. -- `json` : Parse the input as a JSON namespace specification. +- `-json` : Parse the input as a JSON namespace specification. ## Examples @@ -56,7 +56,7 @@ $ nomad namespace apply -quota= api-prod Create a namespace from a file: ```shell-session -$ cat namespace.json +$ cat namespace.hcl name = "dev" description = "Namespace for developers" @@ -64,5 +64,10 @@ capabilities { enabled_task_drivers = ["docker", "exec"] disabled_task_drivers = ["raw_exec"] } -$ nomad namespace apply namespace.json + +meta { + owner = "John Doe" + contact_mail = "john@mycompany.com +} +$ nomad namespace apply namespace.hcl ``` diff --git a/website/content/docs/commands/namespace/status.mdx b/website/content/docs/commands/namespace/status.mdx index 570b065e69a2..5a51e3ac6e90 100644 --- a/website/content/docs/commands/namespace/status.mdx +++ b/website/content/docs/commands/namespace/status.mdx @@ -33,11 +33,14 @@ View the status of a namespace: ```shell-session $ nomad namespace status default -Name = default -Description = Default shared namespace -Quota = shared-default-quota +Name = api-prod +Description = Prod API servers +Quota = prod EnabledDrivers = docker,exec DisabledDrivers = raw_exec + +Metadata +contact = platform-eng@example.com Quota Limits Region CPU Usage Memory Usage