diff --git a/hack/images/jinja2/README.md b/hack/images/jinja2/README.md index c08c0a4f3..4b2ece47e 100644 --- a/hack/images/jinja2/README.md +++ b/hack/images/jinja2/README.md @@ -59,6 +59,14 @@ postgres Prefix for db was removed. This is Jinja limitation. It shouldn't be a big problem as long as there is no need to render the template twice with the same prefix. +### Configuration + +There is a possibility of pre-processing data by setting options in the configuration file. +List of supported operations: +| Name | Default | Description | +| ------------------------- | ------------ | ---------------------------------------------------------------------------------------------------| +| prefix | "" | Adds a prefix to inputted data. The data will be accessible using the set prefix. | +| unpackValue | False | If the `value` prefix is set in the inputted data then the data will be unpacked from that prefix. | ## Prerequisites diff --git a/hack/images/jinja2/jinja2-cli/jinja2cli/cli.py b/hack/images/jinja2/jinja2-cli/jinja2cli/cli.py index 3cfc17925..56bf36bc3 100644 --- a/hack/images/jinja2/jinja2-cli/jinja2cli/cli.py +++ b/hack/images/jinja2/jinja2-cli/jinja2cli/cli.py @@ -312,15 +312,13 @@ def cli(opts, args, config): # noqa: C901 out = codecs.getwriter("utf8")(out) - if config.get("prefix") is not None and len(parsed_data) != 0: - parsed_data = {config["prefix"]: parsed_data} + parsed_data = preprocessing_data(config, parsed_data) template_path = os.path.abspath(template_path) out.write(render(template_path, parsed_data, extensions, opts.filters, opts.strict)) out.flush() return 0 - def parse_kv_string(pairs): dict_ = {} for pair in pairs: @@ -329,6 +327,17 @@ def parse_kv_string(pairs): return dict_ +def preprocessing_data(config, data): + '''Return preprocessed data based on the applied configuration.''' + + if config.get("unpackValue") is True and len(data) != 0 and "value" in data: + data = data.get("value", {}) + + if config.get("prefix") is not None and len(data) != 0: + data = {config["prefix"]: data} + + return data + class LazyHelpOption(Option): "An Option class that resolves help from a callable" diff --git a/hack/images/jinja2/jinja2-cli/tests/test_jinja2cli.py b/hack/images/jinja2/jinja2-cli/tests/test_jinja2cli.py index f2534176d..043f3bb9c 100644 --- a/hack/images/jinja2/jinja2-cli/tests/test_jinja2cli.py +++ b/hack/images/jinja2/jinja2-cli/tests/test_jinja2cli.py @@ -12,76 +12,82 @@ @dataclass -class TestCase: +class RenderTestCase: name: str template: str data: typing.Dict[str, typing.Any] result: str +@dataclass +class PreprocessingDataTestCase: + name: str + config: typing.Dict[str, typing.Any] + data: typing.Dict[str, typing.Any] + result: typing.Dict[str, typing.Any] render_testcases = [ - TestCase(name="empty", template="", data={}, result=""), - TestCase( + RenderTestCase(name="empty", template="", data={}, result=""), + RenderTestCase( name="simple", template="<@ title @>", data={"title": b"\xc3\xb8".decode("utf8")}, result=b"\xc3\xb8".decode("utf8"), ), - TestCase( + RenderTestCase( name="prefix", template="<@ input.key @>", data={"input": {"key": "value"}}, result="value", ), - TestCase( + RenderTestCase( name="two prefixes but one provided", template="<@ input.key @>/<@ additionalinput.key @>", data={"input": {"key": "value"}}, result="value/<@ additionalinput.key @>", ), - TestCase( + RenderTestCase( name="missing prefix", template="<@ input.key @>", data={}, result="<@ input.key @>", ), - TestCase( + RenderTestCase( name="items before attrs", template="<@ input.values.key @>", data={"input": {"values": {"key": "value"}}}, result="value", ), - TestCase( + RenderTestCase( name="attrs still working", template="<@ input.values() @>", data={"input": {}}, result="dict_values([])", ), - TestCase( + RenderTestCase( name="key with dot", template="<@ input['foo.bar'] @>", data={"input": {"foo.bar": "value"}}, result="value", ), - TestCase( + RenderTestCase( name="missing key with dot", template='<@ input["foo.bar"] @>', data={}, result='<@ input["foo.bar"] @>', ), - TestCase( + RenderTestCase( name="use default value", template='<@ input["foo.bar"] | default("hello") @>', data={}, result="hello", ), - TestCase( + RenderTestCase( name="multiple dotted values", template='<@ input.key.key["foo.bar/baz"] | default("hello") @>', data={}, result="hello", ), - TestCase( + RenderTestCase( name="multiline strings", template="""<@ input.key.key["foo.bar/baz"] | default('hello hello') @>""", @@ -100,6 +106,33 @@ def test_render(tmp_path, case): assert output == case.result +preprocessing_data_testcases = [ + PreprocessingDataTestCase( + name="set prefix in the config should prefix the data", + config={"prefix": "testprefix"}, + data = {"test": "test"}, + result={"testprefix": {"test": "test"}} + ), + PreprocessingDataTestCase( + name="set unpackValue in the config should remove the value prefix", + config={"unpackValue": True}, + data = {"value": {"test": "test"}}, + result={"test": "test"} + ), + PreprocessingDataTestCase( + name="set unpackValue and prefix should output correct results", + config={"prefix": "testprefix", "unpackValue": True}, + data = {"value": {"test": "test"}}, + result={"testprefix": {"test": "test"}} + ) +] + +@pytest.mark.parametrize("case", preprocessing_data_testcases) +def test_preprocessing_data(case): + output = cli.preprocessing_data(case.config,case.data) + assert output == case.result + + def test_random_password(tmp_path): random_pass_path = tmp_path / "random.template" random_pass_path.write_text("<@ random_password(length=4) @>") diff --git a/hack/images/merger/README.md b/hack/images/merger/README.md index b00dcb834..de71eee8e 100755 --- a/hack/images/merger/README.md +++ b/hack/images/merger/README.md @@ -4,8 +4,7 @@ This folder contains the Docker image which merges multiple input YAML files into a single one. -The Docker image contains the `merger.sh` helper script. The script is an entrypoint of the image, and it is used to prefix and merge all YAML files found in `$SRC` directory. -Each file is prefixed with a file name without extension. +The Docker image contains the `merger.sh` helper script. The script is an entrypoint of the image, and it is used to prefix and merge all YAML files found in `$SRC` directory. Additionally, if the YAML file contains the `value` key, then it is unpacked from that key. Each file is prefixed with a file name without extension. ## Installation diff --git a/hack/images/merger/merger.sh b/hack/images/merger/merger.sh index 321fcf3b2..ef312934c 100755 --- a/hack/images/merger/merger.sh +++ b/hack/images/merger/merger.sh @@ -3,10 +3,16 @@ SRC=${SRC:-"/yamls"} OUT=${OUT:-"/merged.yaml"} -# prefix each file with its filename for filename in "${SRC}"/*; do filename=$(basename -- "$filename") prefix="${filename%.*}" + + # remove value key if exists + if [[ $(yq e 'has("value")' "${SRC}"/"${filename}") == "true" ]]; then + yq e '.value' -i "${SRC}"/"${filename}" + fi + + # prefix each file with its filename yq e -i "{\"${prefix}\": . }" "${SRC}"/"${filename}" done diff --git a/hub-js/graphql/local/schema.graphql b/hub-js/graphql/local/schema.graphql index db6c754db..6892ede2b 100644 --- a/hub-js/graphql/local/schema.graphql +++ b/hub-js/graphql/local/schema.graphql @@ -123,7 +123,7 @@ type TypeInstanceResourceVersionSpec { abstract: backendRef.abstract, fetchInput: { typeInstance: { resourceVersion: rev.resourceVersion, id: ti.id }, - backend: { context: backendCtx.context, id: backendRef.id} + backend: { context: apoc.convert.fromJsonMap(backendCtx.context), id: backendRef.id} } } AS value RETURN value diff --git a/hub-js/src/local/resolver/mutation/delete-type-instance.ts b/hub-js/src/local/resolver/mutation/delete-type-instance.ts index 7cec7e530..7158947af 100644 --- a/hub-js/src/local/resolver/mutation/delete-type-instance.ts +++ b/hub-js/src/local/resolver/mutation/delete-type-instance.ts @@ -58,7 +58,7 @@ export async function deleteTypeInstance( // NOTE: Need to be preserved with 'WITH' statement, otherwise we won't be able // to access node's properties after 'DETACH DELETE' statement. - WITH *, {id: ti.id, backend: { id: backendRef.id, context: specBackend.context, abstract: backendRef.abstract}} as out + WITH *, {id: ti.id, backend: { id: backendRef.id, context: apoc.convert.fromJsonMap(specBackend.context), abstract: backendRef.abstract}} as out DETACH DELETE ti, metadata, spec, tirs, specBackend WITH * diff --git a/hub-js/src/local/resolver/mutation/lock-type-instances.ts b/hub-js/src/local/resolver/mutation/lock-type-instances.ts index fbddc8b6c..37d318635 100644 --- a/hub-js/src/local/resolver/mutation/lock-type-instances.ts +++ b/hub-js/src/local/resolver/mutation/lock-type-instances.ts @@ -180,7 +180,7 @@ export async function getTypeInstanceStoredExternally( WITH { typeInstanceId: ti.id, - backend: { context: backendCtx.context, id: backendRef.id, abstract: backendRef.abstract} + backend: { context: apoc.convert.fromJsonMap(backendCtx.context), id: backendRef.id, abstract: backendRef.abstract} } AS value RETURN value `, diff --git a/hub-js/src/local/storage/service.ts b/hub-js/src/local/storage/service.ts index 42fa1fb5b..9e031a92e 100644 --- a/hub-js/src/local/storage/service.ts +++ b/hub-js/src/local/storage/service.ts @@ -20,12 +20,6 @@ import { import { JSONSchemaType } from "ajv/lib/types/json-schema"; import { TextEncoder } from "util"; -// TODO(https://github.com/capactio/capact/issues/634): -// Represents the fake storage backend URL that should be ignored -// as the backend server is not deployed. -// It should be removed after a real backend is used in `test/e2e/action_test.go` scenarios. -export const FAKE_TEST_URL = "e2e-test-backend-mock-url:50051"; - type StorageClient = Client; interface BackendContainer { @@ -131,10 +125,6 @@ export default class DelegatedStorageService { backendId: input.backend.id, }); const backend = await this.getBackendContainer(input.backend.id); - if (!backend?.client) { - // TODO: remove after using a real backend in e2e tests. - continue; - } const validateErr = this.validateInput(input, backend.validateSpec); if (validateErr) { @@ -179,11 +169,6 @@ export default class DelegatedStorageService { backendId: input.backend.id, }); const backend = await this.getBackendContainer(input.backend.id); - if (!backend?.client) { - // TODO(https://github.com/capactio/capact/issues/634): remove after using a real backend in e2e tests. - continue; - } - const validateErr = this.validateInput(input, backend.validateSpec); if (validateErr) { throw Error( @@ -220,16 +205,6 @@ export default class DelegatedStorageService { backendId: input.backend.id, }); const backend = await this.getBackendContainer(input.backend.id); - if (!backend?.client) { - // TODO(https://github.com/capactio/capact/issues/634): remove after using a real backend in e2e tests. - result = { - ...result, - [input.typeInstance.id]: { - key: input.backend.id, - }, - }; - continue; - } const validateErr = this.validateInput(input, backend.validateSpec); if (validateErr) { @@ -274,10 +249,6 @@ export default class DelegatedStorageService { backendId: input.backend.id, }); const backend = await this.getBackendContainer(input.backend.id); - if (!backend?.client) { - // TODO(https://github.com/capactio/capact/issues/634): remove after using a real backend in e2e tests. - continue; - } const validateErr = this.validateInput(input, backend.validateSpec); if (validateErr) { @@ -307,10 +278,6 @@ export default class DelegatedStorageService { backendId: input.backend.id, }); const backend = await this.getBackendContainer(input.backend.id); - if (!backend?.client) { - // TODO(https://github.com/capactio/capact/issues/634): remove after using a real backend in e2e tests. - continue; - } const validateErr = this.validateInput(input, backend.validateSpec); if (validateErr) { @@ -341,10 +308,6 @@ export default class DelegatedStorageService { backendId: input.backend.id, }); const backend = await this.getBackendContainer(input.backend.id); - if (!backend?.client) { - // TODO(https://github.com/capactio/capact/issues/634): remove after using a real backend in e2e tests. - continue; - } const validateErr = this.validateInput(input, backend.validateSpec); if (validateErr) { @@ -408,19 +371,9 @@ export default class DelegatedStorageService { } } - private async getBackendContainer( - id: string - ): Promise { + private async getBackendContainer(id: string): Promise { if (!this.registeredClients.has(id)) { const spec = await this.storageInstanceDetailsFetcher(id); - if (spec.url === FAKE_TEST_URL) { - logger.debug( - "Skipping a real call as backend was classified as a fake one" - ); - // TODO(https://github.com/capactio/capact/issues/634): remove after using a real backend in e2e tests. - return undefined; - } - logger.debug("Initialize gRPC BackendContainer", { backend: id, url: spec.url, @@ -451,7 +404,7 @@ export default class DelegatedStorageService { this.registeredClients.set(id, { client, validateSpec: storageSpec }); } - return this.registeredClients.get(id); + return this.registeredClients.get(id) as BackendContainer; } private static convertToJSONIfObject(val: unknown): string | undefined { diff --git a/internal/cli/testing/storage_backend.go b/internal/cli/testing/storage_backend.go index cea21cfbb..6c27a2d73 100644 --- a/internal/cli/testing/storage_backend.go +++ b/internal/cli/testing/storage_backend.go @@ -2,7 +2,6 @@ package testing import ( "context" - "encoding/json" "capact.io/capact/internal/logger" "capact.io/capact/internal/ptr" @@ -44,9 +43,9 @@ const testStorageTypeContextSchema = ` ` type typeInstanceValue struct { - URL string `json:"url"` - AcceptValue bool `json:"acceptValue"` - ContextSchema interface{} `json:"contextSchema"` + URL string `json:"url"` + AcceptValue bool `json:"acceptValue"` + ContextSchema string `json:"contextSchema"` } // StorageBackendRegister provides functionality to produce and upload test storage backend TypeInstance. @@ -80,12 +79,6 @@ func NewStorageBackendRegister() (*StorageBackendRegister, error) { // RegisterTypeInstances produces and uploads TypeInstances which describe Test storage backend. func (i *StorageBackendRegister) RegisterTypeInstances(ctx context.Context) error { - var contextSchema interface{} - err := json.Unmarshal([]byte(testStorageTypeContextSchema), &contextSchema) - if err != nil { - return errors.Wrap(err, "while unmarshaling contextSchema") - } - in := &hublocalgraphql.CreateTypeInstanceInput{ CreatedBy: ptr.String("populator/test-storage-backend-registration"), TypeRef: &hublocalgraphql.TypeInstanceTypeReferenceInput{ @@ -95,7 +88,7 @@ func (i *StorageBackendRegister) RegisterTypeInstances(ctx context.Context) erro Value: typeInstanceValue{ URL: i.cfg.TestStorageBackendURL, AcceptValue: true, - ContextSchema: contextSchema, + ContextSchema: testStorageTypeContextSchema, }, } diff --git a/internal/installation/capact_register.go b/internal/installation/capact_register.go index 41f4dfebc..2bd95b06a 100644 --- a/internal/installation/capact_register.go +++ b/internal/installation/capact_register.go @@ -212,6 +212,7 @@ func (i *CapactRegister) produceConfigTypeInstance(ownerName string, helmRelease if err != nil { return nil, errors.Wrap(err, "while unmarshaling bytes") } + return &gqllocalapi.CreateTypeInstanceInput{ Alias: ptr.String(ownerName), TypeRef: &gqllocalapi.TypeInstanceTypeReferenceInput{ diff --git a/pkg/argo-actions/download_type_instances.go b/pkg/argo-actions/download_type_instances.go index 7ef5c0c7b..de0e3b5b6 100644 --- a/pkg/argo-actions/download_type_instances.go +++ b/pkg/argo-actions/download_type_instances.go @@ -28,6 +28,17 @@ type Download struct { client *hubclient.Client } +// DownloadTypeInstanceData represents the TypeInstance data to download. +type DownloadTypeInstanceData struct { + Value interface{} `json:"value"` + Backend *Backend `json:"backend,omitempty"` +} + +// Backend represents the TypeInstance Backend. +type Backend struct { + Context interface{} `json:"context,omitempty"` +} + // NewDownloadAction returns a new Download instance. func NewDownloadAction(log *zap.Logger, client *hubclient.Client, cfg []DownloadConfig) Action { return &Download{ @@ -49,7 +60,18 @@ func (d *Download) Do(ctx context.Context) error { return fmt.Errorf("failed to find TypeInstance with ID %q", config.ID) } - data, err := yaml.Marshal(typeInstance.LatestResourceVersion.Spec.Value) + typeInstanceData := DownloadTypeInstanceData{ + Value: typeInstance.LatestResourceVersion.Spec.Value, + } + + if typeInstance.LatestResourceVersion.Spec.Backend != nil && + typeInstance.LatestResourceVersion.Spec.Backend.Context != nil { + typeInstanceData.Backend = &Backend{ + Context: typeInstance.LatestResourceVersion.Spec.Backend.Context, + } + } + + data, err := yaml.Marshal(typeInstanceData) if err != nil { return errors.Wrap(err, "while marshaling TypeInstance to YAML") } diff --git a/pkg/argo-actions/update_type_instances.go b/pkg/argo-actions/update_type_instances.go index 522796593..3e43219b2 100644 --- a/pkg/argo-actions/update_type_instances.go +++ b/pkg/argo-actions/update_type_instances.go @@ -2,6 +2,7 @@ package argoactions import ( "context" + "encoding/json" "io/ioutil" "path" "path/filepath" @@ -116,7 +117,26 @@ func (u *Update) render(payload []graphqllocal.UpdateTypeInstancesInput, values return ErrMissingTypeInstanceValue(typeInstance.ID) } - typeInstance.TypeInstance.Value = value + if isTypeInstanceWithLegacySyntax(u.log, value) { + typeInstance.TypeInstance.Value = value + continue + } + + data, err := json.Marshal(value) + if err != nil { + return errors.Wrap(err, "while marshaling TypeInstance") + } + + unmarshalledTI := graphqllocal.UpdateTypeInstanceInput{} + err = json.Unmarshal(data, &unmarshalledTI) + if err != nil { + return errors.Wrap(err, "while unmarshaling TypeInstance") + } + + typeInstance.TypeInstance.Value = unmarshalledTI.Value + if unmarshalledTI.Backend != nil { + typeInstance.TypeInstance.Backend = unmarshalledTI.Backend + } } return nil } diff --git a/pkg/argo-actions/upload_type_instances.go b/pkg/argo-actions/upload_type_instances.go index a65c31fda..3b6a1ad34 100644 --- a/pkg/argo-actions/upload_type_instances.go +++ b/pkg/argo-actions/upload_type_instances.go @@ -2,6 +2,7 @@ package argoactions import ( "context" + "encoding/json" "fmt" "io/ioutil" "path/filepath" @@ -15,8 +16,12 @@ import ( "sigs.k8s.io/yaml" ) -// UploadAction represents the upload TypeInstances action. -const UploadAction = "UploadAction" +const ( + // UploadAction represents the upload TypeInstances action. + UploadAction = "UploadAction" + valueKey = "value" + backendKey = "backend" +) // UploadConfig stores the configuration parameters for the upload TypeInstances action. type UploadConfig struct { @@ -32,6 +37,12 @@ type Upload struct { cfg UploadConfig } +//UploadTypeInstanceData represents the TypeInstance data to upload. +type UploadTypeInstanceData struct { + Value interface{} `json:"value"` + Backend *Backend `json:"backend,omitempty"` +} + // NewUploadAction returns a new Upload instance. func NewUploadAction(log *zap.Logger, client *hubclient.Client, cfg UploadConfig) Action { return &Upload{ @@ -119,7 +130,32 @@ func (u *Upload) render(payload *graphqllocal.CreateTypeInstancesInput, values m return ErrMissingTypeInstanceValue(*typeInstance.Alias) } - typeInstance.Value = value + if isTypeInstanceWithLegacySyntax(u.log, value) { + typeInstance.Value = value + continue + } + + data, err := json.Marshal(value) + if err != nil { + return errors.Wrap(err, "while marshaling TypeInstance") + } + + unmarshalledTI := UploadTypeInstanceData{} + err = json.Unmarshal(data, &unmarshalledTI) + if err != nil { + return errors.Wrap(err, "while unmarshaling TypeInstance") + } + + typeInstance.Value = unmarshalledTI.Value + if unmarshalledTI.Backend != nil { + if typeInstance.Backend != nil { + typeInstance.Backend.Context = unmarshalledTI.Backend.Context + } else { + typeInstance.Backend = &graphqllocal.TypeInstanceBackendInput{ + Context: unmarshalledTI.Backend.Context, + } + } + } } return nil } @@ -127,3 +163,16 @@ func (u *Upload) render(payload *graphqllocal.CreateTypeInstancesInput, values m func (u *Upload) uploadTypeInstances(ctx context.Context, in *graphqllocal.CreateTypeInstancesInput) ([]graphqllocal.CreateTypeInstanceOutput, error) { return u.client.Local.CreateTypeInstances(ctx, in) } + +func isTypeInstanceWithLegacySyntax(logger *zap.Logger, value map[string]interface{}) bool { + _, hasValue := value[valueKey] + _, hasBackend := value[backendKey] + if !hasValue && !hasBackend { + // for backward compatibility, if there is an artifact without value/backend syntax, + // treat it as a value for TypeInstance + logger.Info(fmt.Sprintf("Found legacy TypeInstance syntax without '%s' and '%s'", valueKey, backendKey)) + return true + } + logger.Info(fmt.Sprintf("Processing TypeInstance '%s' and '%s'", valueKey, backendKey), zap.Bool("hasValue", hasValue), zap.Bool("hasBackend", hasBackend)) + return false +} diff --git a/pkg/hub/api/graphql/local/schema_gen.go b/pkg/hub/api/graphql/local/schema_gen.go index b572a979b..53c69f9b6 100644 --- a/pkg/hub/api/graphql/local/schema_gen.go +++ b/pkg/hub/api/graphql/local/schema_gen.go @@ -723,7 +723,7 @@ type TypeInstanceResourceVersionSpec { abstract: backendRef.abstract, fetchInput: { typeInstance: { resourceVersion: rev.resourceVersion, id: ti.id }, - backend: { context: backendCtx.context, id: backendRef.id} + backend: { context: apoc.convert.fromJsonMap(backendCtx.context), id: backendRef.id} } } AS value RETURN value @@ -3438,7 +3438,7 @@ func (ec *executionContext) _TypeInstanceResourceVersionSpec_value(ctx context.C return obj.Value, nil } directive1 := func(ctx context.Context) (interface{}, error) { - statement, err := ec.unmarshalOString2áš–string(ctx, "MATCH (this)<-[:SPECIFIED_BY]-(rev:TypeInstanceResourceVersion)<-[:CONTAINS]-(ti:TypeInstance)\nMATCH (this)-[:WITH_BACKEND]->(backendCtx)\nMATCH (ti)-[:STORED_IN]->(backendRef)\nWITH *\nCALL apoc.when(\n backendRef.abstract,\n '\n WITH {\n abstract: backendRef.abstract,\n builtinValue: apoc.convert.fromJsonMap(spec.value)\n } AS value\n RETURN value\n ',\n '\n WITH {\n abstract: backendRef.abstract,\n fetchInput: {\n typeInstance: { resourceVersion: rev.resourceVersion, id: ti.id },\n backend: { context: backendCtx.context, id: backendRef.id}\n }\n } AS value\n RETURN value\n ',\n {spec: this, rev: rev, ti: ti, backendRef: backendRef, backendCtx: backendCtx}\n) YIELD value as out\n\nRETURN out.value") + statement, err := ec.unmarshalOString2áš–string(ctx, "MATCH (this)<-[:SPECIFIED_BY]-(rev:TypeInstanceResourceVersion)<-[:CONTAINS]-(ti:TypeInstance)\nMATCH (this)-[:WITH_BACKEND]->(backendCtx)\nMATCH (ti)-[:STORED_IN]->(backendRef)\nWITH *\nCALL apoc.when(\n backendRef.abstract,\n '\n WITH {\n abstract: backendRef.abstract,\n builtinValue: apoc.convert.fromJsonMap(spec.value)\n } AS value\n RETURN value\n ',\n '\n WITH {\n abstract: backendRef.abstract,\n fetchInput: {\n typeInstance: { resourceVersion: rev.resourceVersion, id: ti.id },\n backend: { context: apoc.convert.fromJsonMap(backendCtx.context), id: backendRef.id}\n }\n } AS value\n RETURN value\n ',\n {spec: this, rev: rev, ti: ti, backendRef: backendRef, backendCtx: backendCtx}\n) YIELD value as out\n\nRETURN out.value") if err != nil { return nil, err } diff --git a/pkg/runner/common.go b/pkg/runner/common.go index bed808ba7..7de70de7d 100644 --- a/pkg/runner/common.go +++ b/pkg/runner/common.go @@ -4,6 +4,7 @@ import ( "io/ioutil" "github.com/pkg/errors" + "gopkg.in/yaml.v3" ) // DefaultFilePermissions are the default file permissions @@ -18,3 +19,21 @@ func SaveToFile(path string, bytes []byte) error { } return nil } + +// NestingOutputUnderValue write output to "value" key in YAML. +func NestingOutputUnderValue(output []byte) ([]byte, error) { + unmarshalled := map[string]interface{}{} + err := yaml.Unmarshal(output, &unmarshalled) + if err != nil { + return nil, errors.Wrap(err, "while unmarshalling output to map[string]interface{}") + } + + nestingOutputValue := map[string]interface{}{ + "value": unmarshalled, + } + result, err := yaml.Marshal(&nestingOutputValue) + if err != nil { + return nil, errors.Wrap(err, "while marshaling output under a value key") + } + return result, nil +} diff --git a/pkg/runner/helm/helm.go b/pkg/runner/helm/helm.go index df21a7ec6..94c7aae5b 100644 --- a/pkg/runner/helm/helm.go +++ b/pkg/runner/helm/helm.go @@ -122,7 +122,11 @@ func (r *helmRunner) readCommandData(in runner.StartInput) (Input, error) { func (r *helmRunner) saveOutput(out Output) error { r.log.Debug("Saving Helm release output", zap.String("path", r.cfg.Output.HelmReleaseFilePath)) - err := runner.SaveToFile(r.cfg.Output.HelmReleaseFilePath, out.Release) + bytesRelease, err := runner.NestingOutputUnderValue(out.Release) + if err != nil { + return errors.Wrap(err, "while nesting Terrafrom release under value") + } + err = runner.SaveToFile(r.cfg.Output.HelmReleaseFilePath, bytesRelease) if err != nil { return errors.Wrap(err, "while saving Helm release output") } @@ -132,7 +136,11 @@ func (r *helmRunner) saveOutput(out Output) error { } r.log.Debug("Saving additional output", zap.String("path", r.cfg.Output.AdditionalFilePath)) - err = runner.SaveToFile(r.cfg.Output.AdditionalFilePath, out.Additional) + bytesAdditional, err := runner.NestingOutputUnderValue(out.Additional) + if err != nil { + return errors.Wrap(err, "while nesting Terrafrom additional under value") + } + err = runner.SaveToFile(r.cfg.Output.AdditionalFilePath, bytesAdditional) if err != nil { return errors.Wrap(err, "while saving default output") } diff --git a/pkg/runner/helm/types.go b/pkg/runner/helm/types.go index 4936bbdef..e34fd7c41 100644 --- a/pkg/runner/helm/types.go +++ b/pkg/runner/helm/types.go @@ -96,6 +96,11 @@ type ChartRelease struct { Chart Chart `json:"chart"` } +// ChartReleaseInputData represents a Helm chart release input data. +type ChartReleaseInputData struct { + Value ChartRelease `json:"value"` +} + // Input stores the input configuration for the runner. type Input struct { Args Arguments diff --git a/pkg/runner/helm/upgrade.go b/pkg/runner/helm/upgrade.go index 8c4fc9631..20bc9a1a1 100644 --- a/pkg/runner/helm/upgrade.go +++ b/pkg/runner/helm/upgrade.go @@ -110,11 +110,11 @@ func (i *upgrader) loadHelmReleaseData(path string) (ChartRelease, error) { return ChartRelease{}, errors.Wrapf(err, "while reading values from file %q", path) } - var chartRelease ChartRelease - if err := yaml.Unmarshal(bytes, &chartRelease); err != nil { + var chartReleaseIn ChartReleaseInputData + if err := yaml.Unmarshal(bytes, &chartReleaseIn); err != nil { return ChartRelease{}, errors.Wrapf(err, "while parsing %q", path) } - return chartRelease, nil + return chartReleaseIn.Value, nil } func (i *upgrader) mergeHelmChartData(helmRelease ChartRelease, in Input) ChartRelease { diff --git a/pkg/runner/terraform/runner.go b/pkg/runner/terraform/runner.go index 11af36b36..af422c5c4 100644 --- a/pkg/runner/terraform/runner.go +++ b/pkg/runner/terraform/runner.go @@ -223,7 +223,12 @@ func (r *terraformRunner) mergeInputVariables(variables string) error { func (r *terraformRunner) saveOutput(out Output) error { if out.Release != nil { r.log.Debug("Saving terraform release output", zap.String("path", r.cfg.Output.TerraformReleaseFilePath)) - err := runner.SaveToFile(r.cfg.Output.TerraformReleaseFilePath, out.Release) + bytesRelease, err := runner.NestingOutputUnderValue(out.Release) + if err != nil { + return errors.Wrap(err, "while nesting Terrafrom release under value") + } + + err = runner.SaveToFile(r.cfg.Output.TerraformReleaseFilePath, bytesRelease) if err != nil { return errors.Wrap(err, "while saving terraform release output") } @@ -231,7 +236,12 @@ func (r *terraformRunner) saveOutput(out Output) error { if out.Additional != nil { r.log.Debug("Saving additional output", zap.String("path", r.cfg.Output.AdditionalFilePath)) - err := runner.SaveToFile(r.cfg.Output.AdditionalFilePath, out.Additional) + bytesAdditional, err := runner.NestingOutputUnderValue(out.Additional) + if err != nil { + return errors.Wrap(err, "while nesting Terrafrom additional under value") + } + + err = runner.SaveToFile(r.cfg.Output.AdditionalFilePath, bytesAdditional) if err != nil { return errors.Wrap(err, "while saving default output") } @@ -243,7 +253,12 @@ func (r *terraformRunner) saveOutput(out Output) error { return errors.Wrap(err, "while marshaling state") } - err = runner.SaveToFile(r.cfg.Output.TfstateFilePath, stateData) + nestingStateData, err := runner.NestingOutputUnderValue(stateData) + if err != nil { + return errors.Wrap(err, "while nesting Terrafrom state data under value") + } + + err = runner.SaveToFile(r.cfg.Output.TfstateFilePath, nestingStateData) if err != nil { return errors.Wrap(err, "while saving tfstate output") } diff --git a/test/e2e/action_test.go b/test/e2e/action_test.go index add70e865..c0e75f516 100644 --- a/test/e2e/action_test.go +++ b/test/e2e/action_test.go @@ -13,7 +13,6 @@ import ( "strings" "time" - "capact.io/capact/internal/cli/heredoc" "capact.io/capact/internal/ptr" enginegraphql "capact.io/capact/pkg/engine/api/graphql" engine "capact.io/capact/pkg/engine/client" @@ -32,6 +31,7 @@ const ( actionPassingInterfacePath = "cap.interface.capactio.capact.validation.action.passing" uploadTypePath = "cap.type.capactio.capact.validation.upload" singleKeyTypePath = "cap.type.capactio.capact.validation.single-key" + testStorageBackendPath = "cap.type.capactio.capact.validation.storage" ) func getActionName() string { @@ -66,26 +66,28 @@ var _ = Describe("Action", func() { It("should pick Implementation A", func() { implIndicatorValue := "Implementation A" + testStorageBackendTI := getDefaultTestStorageTypeInstance(ctx, hubClient) + backendInput := hublocalgraphql.TypeInstanceBackendInput{ + ID: testStorageBackendTI.ID, + Context: map[string]interface{}{ + "provider": "dotenv", + }, + } // TODO: This can be extracted after switching to ginkgo v2 // see: https://github.com/onsi/ginkgo/issues/70#issuecomment-924250145 By("1. Preparing input Type Instances") By("1.1 Creating TypeInstance which will be downloaded") - download := getTypeInstanceInputForDownload(implIndicatorValue) + download := getTypeInstanceInputForDownload(map[string]interface{}{"key": implIndicatorValue}, nil) downloadTI, downloadTICleanup := createTypeInstance(ctx, hubClient, download) defer downloadTICleanup() By("1.2 Creating TypeInstance which will be downloaded and updated") - update := getTypeInstanceInputForUpdate() + update := getTypeInstanceInputForUpdate(nil) updateTI, updateTICleanup := createTypeInstance(ctx, hubClient, update) defer updateTICleanup() - By("1.3 Creating TypeInstance that describes Helm storage") - helmStorage := fixHelmStorageTypeInstanceCreateInput() - helmStorageTI, helmStorageTICleanup := createTypeInstance(ctx, hubClient, helmStorage) - defer helmStorageTICleanup() - inputData := &enginegraphql.ActionInputData{ TypeInstances: []*enginegraphql.InputTypeInstanceData{ {Name: "testInput", ID: downloadTI.ID}, @@ -123,51 +125,68 @@ var _ = Describe("Action", func() { waitForActionDeleted(ctx, engineClient, actionName) By("6. Modifying Policy to change backend storage for uploaded TypeInstance via TypeRef...") - setGlobalTestPolicy(ctx, engineClient, withHelmBackendForUploadTypeRef(helmStorageTI.ID)) + setGlobalTestPolicy(ctx, engineClient, withTestBackendForUploadTypeRef(testStorageBackendTI.ID)) + + By("7. Creating TypeInstance which will be used by test storage with dotenv provider") + + By("7.1 Creating TypeInstance which will be downloaded") + download2 := getTypeInstanceInputForDownload(map[string]interface{}{"key": implIndicatorValue}, &backendInput) + downloadTI2, downloadTICleanup2 := createTypeInstance(ctx, hubClient, download2) + defer downloadTICleanup2() + + By("7.2 Creating TypeInstance which will be downloaded and updated") + update2 := getTypeInstanceInputForUpdate(&backendInput) + updateTI2, updateTICleanup2 := createTypeInstance(ctx, hubClient, update2) + defer updateTICleanup2() - By("7. Expecting Implementation A is picked and the Helm storage is used for uploaded TypeInstance...") + inputData = &enginegraphql.ActionInputData{ + TypeInstances: []*enginegraphql.InputTypeInstanceData{ + {Name: "testInput", ID: downloadTI2.ID}, + {Name: "testUpdate", ID: updateTI2.ID}, + }, + } + + By("8. Expecting Implementation A is picked and the test storage is used for uploaded TypeInstance...") action = createActionAndWaitForReadyToRunPhase(ctx, engineClient, actionName, actionPassingInterfacePath, inputData) assertActionRenderedWorkflowContains(action, "echo '%s'", implIndicatorValue) runActionAndWaitForSucceeded(ctx, engineClient, actionName) - By("8.1 Check uploaded TypeInstances") - expUploadTIBackend = &hublocalgraphql.TypeInstanceBackendReference{ID: helmStorageTI.ID, Abstract: false} - - // TODO(https://github.com/capactio/capact/issues/634): remove after using a real backend. - // for now, the Local Hub returns backend id under `key` property. - implIndicatorValue = expUploadTIBackend.ID + By("9.1 Check uploaded TypeInstances") + expUploadTIBackend = &hublocalgraphql.TypeInstanceBackendReference{ID: testStorageBackendTI.ID, Abstract: false} + uploadedTI2, cleanupUploaded2 := getUploadedTypeInstanceByValue(ctx, hubClient, implIndicatorValue) + defer cleanupUploaded2() + Expect(uploadedTI2.Backend).Should(Equal(expUploadTIBackend)) - uploadedTI, cleanupUploaded = getUploadedTypeInstanceByValue(ctx, hubClient, implIndicatorValue) - defer cleanupUploaded() // We need to clean it up as it's not deleted when Action is deleted. - Expect(uploadedTI.Backend).Should(Equal(expUploadTIBackend)) - - By("8.2 Check Action output TypeInstances") - uploadedTIOutput = mapToOutputTypeInstanceDetails(uploadedTI, expUploadTIBackend) - assertOutputTypeInstancesInActionStatus(ctx, engineClient, action.Name, And(ContainElements(expUpdatedTIOutput, uploadedTIOutput), HaveLen(2))) + By("9.2 Check Action output TypeInstances") + updateTIOutput := mapToOutputTypeInstanceDetails(updateTI2, expUploadTIBackend) + uploadedTIOutput = mapToOutputTypeInstanceDetails(uploadedTI2, expUploadTIBackend) + assertOutputTypeInstancesInActionStatus(ctx, engineClient, action.Name, And(ContainElements(updateTIOutput, uploadedTIOutput), HaveLen(2))) }) It("should pick Implementation B based on Policy rule", func() { implIndicatorValue := "Implementation B" + testStorageBackendTI := getDefaultTestStorageTypeInstance(ctx, hubClient) + backendInput := hublocalgraphql.TypeInstanceBackendInput{ + ID: testStorageBackendTI.ID, + Context: map[string]interface{}{ + "provider": "dotenv", + }, + } // TODO: This can be extracted after switching to ginkgo v2 // see: https://github.com/onsi/ginkgo/issues/70#issuecomment-924250145 By("1. Preparing input Type Instances") By("1.1 Creating TypeInstance which will be downloaded") - download := getTypeInstanceInputForDownload(implIndicatorValue) + download := getTypeInstanceInputForDownload(map[string]interface{}{"key": implIndicatorValue}, &backendInput) downloadTI, downloadTICleanup := createTypeInstance(ctx, hubClient, download) defer downloadTICleanup() By("1.2 Creating TypeInstance which will be downloaded and updated") - update := getTypeInstanceInputForUpdate() + update := getTypeInstanceInputForUpdate(&backendInput) updateTI, updateTICleanup := createTypeInstance(ctx, hubClient, update) defer updateTICleanup() - By("1.3 Creating TypeInstance that describes Helm storage") - helmStorage := fixHelmStorageTypeInstanceCreateInput() - helmStorageTI, helmStorageTICleanup := createTypeInstance(ctx, hubClient, helmStorage) - defer helmStorageTICleanup() - - By("1.4 Create TypeInstance which is required for Implementation B to be picked based on Policy") + By("1.3 Create TypeInstance which is required for Implementation B to be picked based on Policy") typeInstanceValue := getTypeInstanceInputForPolicy() injectTypeInstance, tiCleanupFn := createTypeInstance(ctx, hubClient, typeInstanceValue) defer tiCleanupFn() @@ -186,26 +205,21 @@ var _ = Describe("Action", func() { Description: ptr.String("Test TypeInstance"), }, { - ID: helmStorageTI.ID, - Description: ptr.String("Helm backend TypeInstance"), + ID: testStorageBackendTI.ID, + Description: ptr.String("Dotenv storage backend TypeInstance"), }, } setGlobalTestPolicy(ctx, engineClient, prependInjectRuleForPassingActionInterface(globalPolicyRequiredTypeInstances)) - By("3. Expecting Implementation B is picked and injected Helm storage is used...") + By("3. Expecting Implementation B is picked and injected test storage is used...") action := createActionAndWaitForReadyToRunPhase(ctx, engineClient, actionName, actionPassingInterfacePath, inputData) assertActionRenderedWorkflowContains(action, "echo '%s'", implIndicatorValue) runActionAndWaitForSucceeded(ctx, engineClient, actionName) By("4.1 Check uploaded TypeInstances") - expUploadTIBackend := &hublocalgraphql.TypeInstanceBackendReference{ID: helmStorageTI.ID, Abstract: false} - - // TODO(https://github.com/capactio/capact/issues/634): remove after using a real backend. - // for now, the Local Hub returns backend id under `key` property. - implIndicatorValue = expUploadTIBackend.ID - + expUploadTIBackend := &hublocalgraphql.TypeInstanceBackendReference{ID: testStorageBackendTI.ID, Abstract: false} uploadedTI, cleanupUploaded := getUploadedTypeInstanceByValue(ctx, hubClient, implIndicatorValue) - defer cleanupUploaded() // We need to clean it up as it's not deleted when Action is deleted. + defer cleanupUploaded() Expect(uploadedTI.Backend).Should(Equal(expUploadTIBackend)) By("4.2 Check Action output TypeInstances") @@ -215,26 +229,28 @@ var _ = Describe("Action", func() { It("should pick Implementation B based on Interface default", func() { implIndicatorValue := "Implementation B" + testStorageBackendTI := getDefaultTestStorageTypeInstance(ctx, hubClient) + backendInput := hublocalgraphql.TypeInstanceBackendInput{ + ID: testStorageBackendTI.ID, + Context: map[string]interface{}{ + "provider": "dotenv", + }, + } // TODO: This can be extracted after switching to ginkgo v2 // see: https://github.com/onsi/ginkgo/issues/70#issuecomment-924250145 By("1. Preparing input Type Instances") By("1.1 Creating TypeInstance which will be downloaded") - download := getTypeInstanceInputForDownload(implIndicatorValue) + download := getTypeInstanceInputForDownload(map[string]interface{}{"key": implIndicatorValue}, &backendInput) downloadTI, downloadTICleanup := createTypeInstance(ctx, hubClient, download) defer downloadTICleanup() By("1.2 Creating TypeInstance which will be downloaded and updated") - update := getTypeInstanceInputForUpdate() + update := getTypeInstanceInputForUpdate(&backendInput) updateTI, updateTICleanup := createTypeInstance(ctx, hubClient, update) defer updateTICleanup() - By("1.3 Creating TypeInstance that describes Helm storage") - helmStorage := fixHelmStorageTypeInstanceCreateInput() - helmStorageTI, helmStorageTICleanup := createTypeInstance(ctx, hubClient, helmStorage) - defer helmStorageTICleanup() - - By("1.4 Create TypeInstance which is required for Implementation B to be picked based on Policy") + By("1.3 Create TypeInstance which is required for Implementation B to be picked based on Policy") typeInstanceValue := getTypeInstanceInputForPolicy() injectTypeInstance, tiCleanupFn := createTypeInstance(ctx, hubClient, typeInstanceValue) defer tiCleanupFn() @@ -253,24 +269,19 @@ var _ = Describe("Action", func() { Description: ptr.String("Test TypeInstance"), }, { - ID: helmStorageTI.ID, - Description: ptr.String("Helm backend TypeInstance"), + ID: testStorageBackendTI.ID, + Description: ptr.String("Dotenv storage backend TypeInstance"), }, } setGlobalTestPolicy(ctx, engineClient, addInterfacePolicyDefaultInjectionForPassingActionInterface(globalPolicyRequiredTypeInstances)) - By("3. Expecting Implementation B is picked and injected Helm storage is used...") + By("3. Expecting Implementation B is picked and injected test storage is used...") action := createActionAndWaitForReadyToRunPhase(ctx, engineClient, actionName, actionPassingInterfacePath, inputData) assertActionRenderedWorkflowContains(action, "echo '%s'", implIndicatorValue) runActionAndWaitForSucceeded(ctx, engineClient, actionName) By("4.1 Check uploaded TypeInstances") - expUploadTIBackend := &hublocalgraphql.TypeInstanceBackendReference{ID: helmStorageTI.ID, Abstract: false} - - // TODO(https://github.com/capactio/capact/issues/634): remove after using a real backend. - // for now, the Local Hub returns backend id under `key` property. - implIndicatorValue = expUploadTIBackend.ID - + expUploadTIBackend := &hublocalgraphql.TypeInstanceBackendReference{ID: testStorageBackendTI.ID, Abstract: false} uploadedTI, cleanupUploaded := getUploadedTypeInstanceByValue(ctx, hubClient, implIndicatorValue) defer cleanupUploaded() // We need to clean it up as it's not deleted when Action is deleted. Expect(uploadedTI.Backend).Should(Equal(expUploadTIBackend)) @@ -311,7 +322,7 @@ var _ = Describe("Action", func() { By("Prepare TypeInstance to update") - update := getTypeInstanceInputForUpdate() + update := getTypeInstanceInputForUpdate(nil) updateTI, updateTICleanup := createTypeInstance(ctx, hubClient, update) defer updateTICleanup() @@ -439,13 +450,14 @@ func getTypeInstanceInputForPolicy() *hublocalgraphql.CreateTypeInstanceInput { } } -func getTypeInstanceInputForDownload(testValue string) *hublocalgraphql.CreateTypeInstanceInput { +func getTypeInstanceInputForDownload(testValues map[string]interface{}, backendInput *hublocalgraphql.TypeInstanceBackendInput) *hublocalgraphql.CreateTypeInstanceInput { return &hublocalgraphql.CreateTypeInstanceInput{ TypeRef: &hublocalgraphql.TypeInstanceTypeReferenceInput{ Path: "cap.type.capactio.capact.validation.download", Revision: "0.1.0", }, - Value: map[string]interface{}{"key": testValue}, + Value: testValues, + Backend: backendInput, Attributes: []*hublocalgraphql.AttributeReferenceInput{ { Path: "cap.attribute.capactio.capact.attribute1", @@ -455,13 +467,14 @@ func getTypeInstanceInputForDownload(testValue string) *hublocalgraphql.CreateTy } } -func getTypeInstanceInputForUpdate() *hublocalgraphql.CreateTypeInstanceInput { +func getTypeInstanceInputForUpdate(backendInput *hublocalgraphql.TypeInstanceBackendInput) *hublocalgraphql.CreateTypeInstanceInput { return &hublocalgraphql.CreateTypeInstanceInput{ TypeRef: &hublocalgraphql.TypeInstanceTypeReferenceInput{ Path: "cap.type.capactio.capact.validation.update", Revision: "0.1.0", }, - Value: map[string]interface{}{"key": "random text to update"}, + Value: map[string]interface{}{"key": "random text to update"}, + Backend: backendInput, Attributes: []*hublocalgraphql.AttributeReferenceInput{ { Path: "cap.attribute.capactio.capact.attribute1", @@ -471,40 +484,6 @@ func getTypeInstanceInputForUpdate() *hublocalgraphql.CreateTypeInstanceInput { } } -func fixHelmStorageTypeInstanceCreateInput() *hublocalgraphql.CreateTypeInstanceInput { - return &hublocalgraphql.CreateTypeInstanceInput{ - TypeRef: &hublocalgraphql.TypeInstanceTypeReferenceInput{ - Path: "cap.type.helm.storage", - Revision: "0.1.0", - }, - Attributes: []*hublocalgraphql.AttributeReferenceInput{}, - Value: map[string]interface{}{ - "url": "e2e-test-backend-mock-url:50051", - "acceptValue": true, - "contextSchema": heredoc.Doc(` - { - "$id": "#/properties/contextSchema", - "type": "object", - "required": [ - "name", - "namespace" - ], - "properties": { - "name": { - "$id": "#/properties/contextSchema/properties/name", - "type": "string" - }, - "namespace": { - "$id": "#/properties/contextSchema/properties/namespace", - "type": "string" - } - }, - "additionalProperties": false - }`), - }, - } -} - func createActionAndWaitForReadyToRunPhase(ctx context.Context, engineClient *engine.Client, actionName, actionPath string, input *enginegraphql.ActionInputData) *enginegraphql.Action { _, err := engineClient.CreateAction(ctx, &enginegraphql.ActionDetailsInput{ Name: actionName, @@ -579,7 +558,7 @@ func createTypeInstance(ctx context.Context, hubClient *hubclient.Client, in *hu type policyOption func(*enginegraphql.PolicyInput) -func withHelmBackendForUploadTypeRef(backendID string) policyOption { +func withTestBackendForUploadTypeRef(backendID string) policyOption { return func(policy *enginegraphql.PolicyInput) { policy.TypeInstance = &enginegraphql.TypeInstancePolicyInput{ Rules: []*enginegraphql.RulesForTypeInstanceInput{ @@ -677,6 +656,18 @@ func getTypeInstanceByIDAndValue(ctx context.Context, hubClient *hubclient.Clien return updateTI } +func getDefaultTestStorageTypeInstance(ctx context.Context, hubClient *hubclient.Client) *hublocalgraphql.TypeInstance { + storage, err := hubClient.ListTypeInstances(ctx, &hublocalgraphql.TypeInstanceFilter{ + TypeRef: &hublocalgraphql.TypeRefFilterInput{ + Path: testStorageBackendPath, + Revision: ptr.String("0.1.0"), + }, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(len(storage)).Should(Equal(1)) + return &storage[0] +} + func getUploadedTypeInstanceByValue(ctx context.Context, hubClient *hubclient.Client, expValue string) (*hublocalgraphql.TypeInstance, func()) { uploaded, err := hubClient.ListTypeInstances(ctx, &hublocalgraphql.TypeInstanceFilter{ TypeRef: &hublocalgraphql.TypeRefFilterInput{ diff --git a/test/e2e/cluster_test.go b/test/e2e/cluster_test.go index 916862b5a..e65cd2113 100644 --- a/test/e2e/cluster_test.go +++ b/test/e2e/cluster_test.go @@ -1,3 +1,4 @@ +//go:build integration // +build integration package e2e