Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 28 additions & 4 deletions internal/apply/README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# nais apply

Apply Valkey and OpenSearch resources to a Nais environment.
Apply Valkey, OpenSearch and Config resources to a Nais environment.

## Usage

Expand Down Expand Up @@ -54,9 +54,12 @@ spec:
...
```

Resources that have a dedicated nais-api mutation (Valkey, OpenSearch) are
created or updated through that mutation. Other kinds are converted back into a
native CRD and sent to the generic apply endpoint.
Some resources (e.g. Config) use `data`/`binaryData` at the top level instead
of `spec`. See the Config section below.

Resources that have a dedicated nais-api mutation (Valkey, OpenSearch, Config)
are created or updated through that mutation. Other kinds are converted back
into a native CRD and sent to the generic apply endpoint.

Multiple resources can be placed in the same file separated by `---`, and the
native format may be mixed with regular Kubernetes CRDs (see below).
Expand Down Expand Up @@ -117,6 +120,27 @@ spec:
storageGB: 50
```

### Config

Config uses `data` and `binaryData` at the top level instead of `spec`. The
manifest is fully declarative: keys present in the manifest are added or
updated, and keys missing from the manifest are removed.

```yaml
version: v1
kind: Config
metadata:
name: my-config
labels: # optional
purpose: backend
data:
DATABASE_HOST: db.example.com
LOG_LEVEL: info
PORT: "8080"
binaryData: # optional; values must be base64-encoded
keystore.p12: aGVsbG8gd29ybGQ=
```

### Multi-resource file

```yaml
Expand Down
2 changes: 1 addition & 1 deletion internal/apply/apply.go
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ func Run(ctx context.Context, filePath string, flags *flag.Apply, out *naistrix.
TeamSlug: flags.Team,
EnvironmentName: environment,
Labels: m.Labels,
}, &m.Spec)
}, m)
if err != nil {
out.Warnf("%s/%s: %v\n", m.Kind, m.Name, err)
errs = append(errs, fmt.Sprintf("%s/%s: %v", m.Kind, m.Name, err))
Expand Down
110 changes: 110 additions & 0 deletions internal/apply/resource/config.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
package resource

import (
"context"
"fmt"

"github.com/nais/cli/internal/config"
"github.com/nais/cli/internal/naisapi"
"github.com/nais/cli/internal/naisapi/gql"
)

func init() {
register(configResource{kindSupport{kind: "Config", strippedVersion: "v1"}})
}

// configResource applies Config instances through the nais-api Config mutations.
// Unlike Valkey and OpenSearch, Config uses top-level data/binaryData fields
// instead of spec, and the API operates on individual values rather than a
// single create-or-update mutation.
type configResource struct{ kindSupport }

func (c configResource) Apply(ctx context.Context, meta Metadata, m Manifest) (Action, error) {
cmeta := config.Metadata{
Name: meta.Name,
TeamSlug: meta.TeamSlug,
EnvironmentName: meta.EnvironmentName,
}

existing, action, err := c.ensureExists(ctx, cmeta)
if err != nil {
return "", err
}

// Build a set of existing values for efficient lookup.
existingValues := make(map[string]gql.GetConfigTeamEnvironmentConfigValuesConfigValue, len(existing.Values))
for _, v := range existing.Values {
existingValues[v.Name] = v
}

// Desired state: merge data (PLAIN_TEXT) and binaryData (BASE64).
desired := make(map[string]configValue, len(m.Data)+len(m.BinaryData))
for k, v := range m.Data {
desired[k] = configValue{value: v, encoding: gql.ValueEncodingPlainText}
}
for k, v := range m.BinaryData {
desired[k] = configValue{value: v, encoding: gql.ValueEncodingBase64}
}

// Add or update values present in the manifest.
for key, dv := range desired {
if ev, exists := existingValues[key]; exists {
if ev.Value == dv.value && ev.Encoding == dv.encoding {
continue // unchanged
}
if _, err := config.SetValue(ctx, cmeta, key, dv.value, dv.encoding); err != nil {
return "", fmt.Errorf("updating value %q: %w", key, err)
}
action = ActionUpdated
} else {
if _, err := config.SetValue(ctx, cmeta, key, dv.value, dv.encoding); err != nil {
return "", fmt.Errorf("adding value %q: %w", key, err)
}
if action != ActionCreated {
action = ActionUpdated
}
}
}

// Remove values not present in the manifest.
for key := range existingValues {
if _, keep := desired[key]; !keep {
if err := config.RemoveValue(ctx, cmeta, key); err != nil {
return "", fmt.Errorf("removing value %q: %w", key, err)
}
action = ActionUpdated
}
}

return action, nil
}

type configValue struct {
value string
encoding gql.ValueEncoding
}

// ensureExists creates the config if it does not exist, returning the current
// state and whether it was just created.
func (c configResource) ensureExists(ctx context.Context, meta config.Metadata) (*gql.GetConfigTeamEnvironmentConfig, Action, error) {
existing, err := config.Get(ctx, meta)
if err == nil {
return existing, ActionUpdated, nil
}

if !naisapi.IsNotFound(err) {
return nil, "", err
}

if _, err := config.Create(ctx, meta); err != nil {
return nil, "", fmt.Errorf("creating config: %w", err)
}

// Fetch the newly created config to get a consistent state.
existing, err = config.Get(ctx, meta)
if err != nil {
return nil, "", fmt.Errorf("fetching newly created config: %w", err)
}

return existing, ActionCreated, nil
}
5 changes: 2 additions & 3 deletions internal/apply/resource/opensearch.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import (
"github.com/nais/cli/internal/naisapi"
"github.com/nais/cli/internal/naisapi/gql"
"github.com/nais/cli/internal/opensearch"
"gopkg.in/yaml.v3"
)

func init() {
Expand Down Expand Up @@ -46,9 +45,9 @@ var (
}
)

func (o openSearchResource) Apply(ctx context.Context, meta Metadata, spec *yaml.Node) (Action, error) {
func (o openSearchResource) Apply(ctx context.Context, meta Metadata, m Manifest) (Action, error) {
var s openSearchSpec
if err := decodeSpec(spec, &s); err != nil {
if err := decodeSpec(&m.Spec, &s); err != nil {
return "", err
}

Expand Down
13 changes: 11 additions & 2 deletions internal/apply/resource/parse.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ var allowedVersions = map[string]struct{}{
// allowedTopLevel and allowedMetadata are the only fields a stripped manifest may
// contain; anything else is reported as an ignored field.
var (
allowedTopLevel = map[string]struct{}{"version": {}, "kind": {}, "metadata": {}, "spec": {}}
allowedTopLevel = map[string]struct{}{"version": {}, "kind": {}, "metadata": {}, "spec": {}, "data": {}, "binaryData": {}}
allowedMetadata = map[string]struct{}{"name": {}, "labels": {}}
)

Expand All @@ -32,6 +32,11 @@ type Manifest struct {
Labels map[string]string
Spec yaml.Node

// Data holds top-level key-value data for resources that use data/binaryData
// instead of spec (e.g. Config).
Data map[string]string
BinaryData map[string]string

// IgnoredFields are envelope fields that are not part of the nais-native
// format (e.g. "metadata.namespace").
IgnoredFields []string
Expand Down Expand Up @@ -80,7 +85,9 @@ func ParseManifest(root *yaml.Node) (Manifest, error) {
Name string `yaml:"name"`
Labels map[string]string `yaml:"labels,omitempty"`
} `yaml:"metadata"`
Spec yaml.Node `yaml:"spec"`
Spec yaml.Node `yaml:"spec"`
Data map[string]string `yaml:"data,omitempty"`
BinaryData map[string]string `yaml:"binaryData,omitempty"`
}
if err := root.Decode(&raw); err != nil {
return Manifest{}, fmt.Errorf("failed to decode manifest: %w", err)
Expand All @@ -105,6 +112,8 @@ func ParseManifest(root *yaml.Node) (Manifest, error) {
Name: raw.Metadata.Name,
Spec: raw.Spec,
Labels: raw.Metadata.Labels,
Data: raw.Data,
BinaryData: raw.BinaryData,
IgnoredFields: ignoredFields(root),
}, nil
}
Expand Down
73 changes: 73 additions & 0 deletions internal/apply/resource/parse_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,79 @@ func TestDocuments_SplitsAndSkipsEmpty(t *testing.T) {
}
}

func TestParse_Config(t *testing.T) {
manifests, err := Parse([]byte(`
version: v1
kind: Config
metadata:
name: my-config
labels:
purpose: backend
data:
DATABASE_HOST: db.example.com
LOG_LEVEL: info
PORT: "8080"
binaryData:
keystore.p12: aGVsbG8gd29ybGQ=
`))
if err != nil {
t.Fatalf("Parse: %v", err)
}
if len(manifests) != 1 {
t.Fatalf("expected 1 manifest, got %d", len(manifests))
}

m := manifests[0]
if got, want := m.Kind, "Config"; got != want {
t.Errorf("kind = %q, want %q", got, want)
}
if got, want := m.Name, "my-config"; got != want {
t.Errorf("name = %q, want %q", got, want)
}
if len(m.IgnoredFields) != 0 {
t.Errorf("expected no ignored fields, got %v", m.IgnoredFields)
}

wantLabels := map[string]string{"purpose": "backend"}
if diff := cmp.Diff(wantLabels, m.Labels); diff != "" {
t.Errorf("labels mismatch (-want +got):\n%s", diff)
}

wantData := map[string]string{
"DATABASE_HOST": "db.example.com",
"LOG_LEVEL": "info",
"PORT": "8080",
}
if diff := cmp.Diff(wantData, m.Data); diff != "" {
t.Errorf("data mismatch (-want +got):\n%s", diff)
}

wantBinaryData := map[string]string{
"keystore.p12": "aGVsbG8gd29ybGQ=",
}
if diff := cmp.Diff(wantBinaryData, m.BinaryData); diff != "" {
t.Errorf("binaryData mismatch (-want +got):\n%s", diff)
}
}

func TestParse_ConfigNoIgnoredFields(t *testing.T) {
// Config uses data/binaryData at top level — these should NOT be ignored fields.
manifests, err := Parse([]byte(`
version: v1
kind: Config
metadata:
name: test
data:
KEY: value
`))
if err != nil {
t.Fatalf("Parse: %v", err)
}
if len(manifests[0].IgnoredFields) != 0 {
t.Errorf("expected no ignored fields, got %v", manifests[0].IgnoredFields)
}
}

func mustDocument(t *testing.T, manifest string) *yaml.Node {
t.Helper()
docs, err := Documents([]byte(manifest))
Expand Down
3 changes: 1 addition & 2 deletions internal/apply/resource/resource.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ import (
"time"

"github.com/nais/naistrix"
"gopkg.in/yaml.v3"
)

// Resource is a kind that `nais apply` knows about, scoped to the manifest
Expand All @@ -36,7 +35,7 @@ type Resource interface {
// Applier is implemented by resources with a dedicated nais-api mutation, using
// create-or-update semantics.
type Applier interface {
Apply(ctx context.Context, meta Metadata, spec *yaml.Node) (Action, error)
Apply(ctx context.Context, meta Metadata, m Manifest) (Action, error)
}

// Waiter is implemented by resources that support --wait. since is the apply
Expand Down
3 changes: 3 additions & 0 deletions internal/apply/resource/resource_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,9 @@ func TestForManifest(t *testing.T) {
"opensearch v1 resolves to a mutation": {
kind: "OpenSearch", version: "v1", wantFound: true, wantApplier: true, wantWaiter: false,
},
"config v1 resolves to a mutation": {
kind: "Config", version: "v1", wantFound: true, wantApplier: true, wantWaiter: false,
},
"application is not handled as a stripped manifest": {
kind: "Application", version: "v1", wantFound: false,
},
Expand Down
5 changes: 2 additions & 3 deletions internal/apply/resource/valkey.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import (
"github.com/nais/cli/internal/naisapi"
"github.com/nais/cli/internal/naisapi/gql"
"github.com/nais/cli/internal/valkey"
"gopkg.in/yaml.v3"
)

func init() {
Expand Down Expand Up @@ -63,9 +62,9 @@ var (
}
)

func (v valkeyResource) Apply(ctx context.Context, meta Metadata, spec *yaml.Node) (Action, error) {
func (v valkeyResource) Apply(ctx context.Context, meta Metadata, m Manifest) (Action, error) {
var s valkeySpec
if err := decodeSpec(spec, &s); err != nil {
if err := decodeSpec(&m.Spec, &s); err != nil {
return "", err
}

Expand Down
10 changes: 10 additions & 0 deletions internal/apply/testdata/config.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
version: v1
kind: Config
metadata:
name: my-backend-config
data:
DATABASE_HOST: db.example.com
LOG_LEVEL: info
PORT: "8080"
binaryData:
keystore.p12: aGVsbG8gd29ybGQ=
Loading
Loading