diff --git a/.gitignore b/.gitignore index 33d2eca06e..ca39ed30c6 100644 --- a/.gitignore +++ b/.gitignore @@ -54,3 +54,6 @@ dist/ # Go workspace file go.work go.work.sum + +# Fetched based on recorded hash +.codegen/openapi.json diff --git a/Makefile b/Makefile index fe6214d28e..6902fe4b4d 100644 --- a/Makefile +++ b/Makefile @@ -170,6 +170,21 @@ generate: @echo "Generating CLI code..." $(GENKIT_BINARY) update-sdk +.codegen/openapi.json: .codegen/_openapi_sha + wget -O $@.tmp "https://openapi.dev.databricks.com/$$(cat $<)/specs/all-internal.json" && mv $@.tmp $@ && touch $@ + +generate-direct: generate-direct-apitypes generate-direct-resources +generate-direct-apitypes: bundle/direct/dresources/apitypes.generated.yml +generate-direct-resources: bundle/direct/dresources/resources.generated.yml +generate-direct-clean: + rm -f bundle/direct/dresources/apitypes.generated.yml bundle/direct/dresources/resources.generated.yml +.PHONY: generate-direct generate-direct-apitypes generate-direct-resources generate-direct-clean + +bundle/direct/dresources/apitypes.generated.yml: ./bundle/direct/tools/generate_apitypes.py .codegen/openapi.json acceptance/bundle/refschema/out.fields.txt + python3 $^ > $@ + +bundle/direct/dresources/resources.generated.yml: ./bundle/direct/tools/generate_resources.py .codegen/openapi.json bundle/direct/dresources/apitypes.generated.yml acceptance/bundle/refschema/out.fields.txt + python3 $^ > $@ .PHONY: lint lintfull tidy lintcheck fmt fmtfull test test-unit test-acc test-slow test-slow-unit test-slow-acc cover showcover build snapshot snapshot-release schema integration integration-short acc-cover acc-showcover docs ws wsfix links checks test-update test-update-templates generate-out-test-toml test-update-aws test-update-all generate-validation diff --git a/bundle/deployplan/plan.go b/bundle/deployplan/plan.go index b5f8b0975f..0601bef38b 100644 --- a/bundle/deployplan/plan.go +++ b/bundle/deployplan/plan.go @@ -102,6 +102,7 @@ const ( ReasonAlias = "alias" ReasonRemoteAlreadySet = "remote_already_set" ReasonBuiltinRule = "builtin_rule" + ReasonAPISchema = "api_schema" ReasonEmptySlice = "empty_slice" ReasonEmptyMap = "empty_map" ReasonEmptyStruct = "empty_struct" diff --git a/bundle/direct/bundle_plan.go b/bundle/direct/bundle_plan.go index a1750e953e..6ad096bcb7 100644 --- a/bundle/direct/bundle_plan.go +++ b/bundle/direct/bundle_plan.go @@ -360,6 +360,7 @@ func prepareChanges(ctx context.Context, adapter *dresources.Adapter, localDiff, func addPerFieldActions(ctx context.Context, adapter *dresources.Adapter, changes deployplan.Changes, remoteState any) error { cfg := adapter.ResourceConfig() + generatedCfg := adapter.GeneratedResourceConfig() for pathString, ch := range changes { path, err := structpath.Parse(pathString) @@ -385,6 +386,9 @@ func addPerFieldActions(ctx context.Context, adapter *dresources.Adapter, change } else if shouldSkip(cfg, path, ch) { ch.Action = deployplan.Skip ch.Reason = deployplan.ReasonBuiltinRule + } else if shouldSkip(generatedCfg, path, ch) { + ch.Action = deployplan.Skip + ch.Reason = deployplan.ReasonAPISchema } else if ch.New == nil && ch.Old == nil && ch.Remote != nil && path.IsDotString() { // The field was not set by us, but comes from the remote state. // This could either be server-side default or a policy. @@ -395,6 +399,9 @@ func addPerFieldActions(ctx context.Context, adapter *dresources.Adapter, change } else if action := shouldUpdateOrRecreate(cfg, path); action != deployplan.Undefined { ch.Action = action ch.Reason = deployplan.ReasonBuiltinRule + } else if action := shouldUpdateOrRecreate(generatedCfg, path); action != deployplan.Undefined { + ch.Action = action + ch.Reason = deployplan.ReasonAPISchema } else { ch.Action = deployplan.Update } diff --git a/bundle/direct/dresources/adapter.go b/bundle/direct/dresources/adapter.go index bbce13b80f..773e414db8 100644 --- a/bundle/direct/dresources/adapter.go +++ b/bundle/direct/dresources/adapter.go @@ -94,8 +94,9 @@ type Adapter struct { overrideChangeDesc *calladapt.BoundCaller doResize *calladapt.BoundCaller - resourceConfig *ResourceLifecycleConfig - keyedSlices map[string]any + resourceConfig *ResourceLifecycleConfig + generatedResourceConfig *ResourceLifecycleConfig + keyedSlices map[string]any } func NewAdapter(typedNil any, resourceType string, client *databricks.WorkspaceClient) (*Adapter, error) { @@ -112,19 +113,20 @@ func NewAdapter(typedNil any, resourceType string, client *databricks.WorkspaceC } impl := outs[0] adapter := &Adapter{ - prepareState: nil, - remapState: nil, - doRefresh: nil, - doDelete: nil, - doCreate: nil, - doUpdate: nil, - doUpdateWithID: nil, - doResize: nil, - waitAfterCreate: nil, - waitAfterUpdate: nil, - overrideChangeDesc: nil, - resourceConfig: GetResourceConfig(resourceType), - keyedSlices: nil, + prepareState: nil, + remapState: nil, + doRefresh: nil, + doDelete: nil, + doCreate: nil, + doUpdate: nil, + doUpdateWithID: nil, + doResize: nil, + waitAfterCreate: nil, + waitAfterUpdate: nil, + overrideChangeDesc: nil, + resourceConfig: GetResourceConfig(resourceType), + generatedResourceConfig: GetGeneratedResourceConfig(resourceType), + keyedSlices: nil, } err = adapter.initMethods(impl) @@ -355,6 +357,10 @@ func (a *Adapter) ResourceConfig() *ResourceLifecycleConfig { return a.resourceConfig } +func (a *Adapter) GeneratedResourceConfig() *ResourceLifecycleConfig { + return a.generatedResourceConfig +} + func (a *Adapter) IsFieldInRecreateOnChanges(path *structpath.PathNode) bool { for _, p := range a.resourceConfig.RecreateOnChanges { if path.HasPrefix(p) { diff --git a/bundle/direct/dresources/all_test.go b/bundle/direct/dresources/all_test.go index bfefdb82eb..82cac384a0 100644 --- a/bundle/direct/dresources/all_test.go +++ b/bundle/direct/dresources/all_test.go @@ -659,51 +659,54 @@ func testCRUD(t *testing.T, group string, adapter *Adapter, client *databricks.W } } -// validateFields uses structwalk to generate all valid field paths and checks membership. -func validateFields(t *testing.T, configType reflect.Type, fields map[string]deployplan.ActionType) { - validPaths := make(map[string]struct{}) - - err := structwalk.WalkType(configType, func(path *structpath.PathNode, typ reflect.Type, field *reflect.StructField) bool { - validPaths[path.String()] = struct{}{} - return true // continue walking - }) - require.NoError(t, err) +// TestResourceConfig validates that all field patterns in resource config +// exist in the corresponding StateType for each resource. +func TestResourceConfig(t *testing.T) { + for resourceType, resource := range SupportedResources { + adapter, err := NewAdapter(resource, resourceType, nil) + require.NoError(t, err) - for fieldPath := range fields { - if _, exists := validPaths[fieldPath]; !exists { - t.Errorf("invalid field '%s' for %s", fieldPath, configType) + cfg := adapter.ResourceConfig() + if cfg == nil { + continue } + + t.Run(resourceType, func(t *testing.T) { + validateResourceConfig(t, adapter.StateType(), cfg) + }) } } -// TestResourceConfig validates that all field patterns in resource config +// TestGeneratedResourceConfig validates that all field patterns in generated resource config // exist in the corresponding StateType for each resource. -func TestResourceConfig(t *testing.T) { +func TestGeneratedResourceConfig(t *testing.T) { for resourceType, resource := range SupportedResources { adapter, err := NewAdapter(resource, resourceType, nil) require.NoError(t, err) - cfg := adapter.ResourceConfig() + cfg := adapter.GeneratedResourceConfig() if cfg == nil { continue } t.Run(resourceType, func(t *testing.T) { - fieldMap := make(map[string]deployplan.ActionType) - for _, p := range cfg.RecreateOnChanges { - fieldMap[p.String()] = deployplan.Recreate - } - for _, p := range cfg.UpdateIDOnChanges { - fieldMap[p.String()] = deployplan.UpdateWithID - } - for _, p := range cfg.IgnoreRemoteChanges { - fieldMap[p.String()] = deployplan.Skip - } - validateFields(t, adapter.StateType(), fieldMap) + validateResourceConfig(t, adapter.StateType(), cfg) }) } } +func validateResourceConfig(t *testing.T, stateType reflect.Type, cfg *ResourceLifecycleConfig) { + for _, p := range cfg.RecreateOnChanges { + assert.NoError(t, structaccess.Validate(stateType, p), "RecreateOnChanges: %s", p) + } + for _, p := range cfg.UpdateIDOnChanges { + assert.NoError(t, structaccess.Validate(stateType, p), "UpdateIDOnChanges: %s", p) + } + for _, p := range cfg.IgnoreRemoteChanges { + assert.NoError(t, structaccess.Validate(stateType, p), "IgnoreRemoteChanges: %s", p) + } +} + func setupTestServerClient(t *testing.T) (*testserver.Server, *databricks.WorkspaceClient) { server := testserver.New(t) testserver.AddDefaultHandlers(server) diff --git a/bundle/direct/dresources/apitypes.generated.yml b/bundle/direct/dresources/apitypes.generated.yml new file mode 100644 index 0000000000..9827b73a3f --- /dev/null +++ b/bundle/direct/dresources/apitypes.generated.yml @@ -0,0 +1,37 @@ +# Generated, do not edit. Override via apitypes.yml + +alerts: sql.AlertV2 + +apps: apps.App + +clusters: compute.ClusterSpec + +dashboards: dashboards.Dashboard + +database_catalogs: database.DatabaseCatalog + +database_instances: database.DatabaseInstance + +experiments: ml.CreateExperiment + +jobs: jobs.JobSettings + +model_serving_endpoints: serving.CreateServingEndpoint + +models: ml.CreateModelRequest + +pipelines: pipelines.CreatePipeline + +quality_monitors: catalog.CreateMonitor + +registered_models: catalog.RegisteredModelInfo + +schemas: catalog.CreateSchema + +secret_scopes: workspace.CreateScope + +sql_warehouses: sql.EditWarehouseRequest + +synced_database_tables: database.SyncedDatabaseTable + +volumes: catalog.CreateVolumeRequestContent diff --git a/bundle/direct/dresources/config.go b/bundle/direct/dresources/config.go index f6a7790f88..bc6d77d34d 100644 --- a/bundle/direct/dresources/config.go +++ b/bundle/direct/dresources/config.go @@ -31,9 +31,14 @@ type Config struct { //go:embed resources.yml var resourcesYAML []byte +//go:embed resources.generated.yml +var resourcesGeneratedYAML []byte + var ( - configOnce sync.Once - globalConfig *Config + configOnce sync.Once + globalConfig *Config + generatedConfigOnce sync.Once + generatedConfig *Config ) // MustLoadConfig loads and parses the embedded resources.yml configuration. @@ -51,6 +56,21 @@ func MustLoadConfig() *Config { return globalConfig } +// MustLoadGeneratedConfig loads and parses the embedded resources.generated.yml configuration. +// The config is loaded once and cached for subsequent calls. +// Panics if the embedded YAML is invalid. +func MustLoadGeneratedConfig() *Config { + generatedConfigOnce.Do(func() { + generatedConfig = &Config{ + Resources: nil, + } + if err := yaml.Unmarshal(resourcesGeneratedYAML, generatedConfig); err != nil { + panic(err) + } + }) + return generatedConfig +} + // GetResourceConfig returns the lifecycle config for a given resource type. // Returns nil if the resource type has no configuration. func GetResourceConfig(resourceType string) *ResourceLifecycleConfig { @@ -60,3 +80,13 @@ func GetResourceConfig(resourceType string) *ResourceLifecycleConfig { } return nil } + +// GetGeneratedResourceConfig returns the generated lifecycle config for a given resource type. +// Returns nil if the resource type has no configuration. +func GetGeneratedResourceConfig(resourceType string) *ResourceLifecycleConfig { + cfg := MustLoadGeneratedConfig() + if rc, ok := cfg.Resources[resourceType]; ok { + return &rc + } + return nil +} diff --git a/bundle/direct/dresources/resources.generated.yml b/bundle/direct/dresources/resources.generated.yml new file mode 100644 index 0000000000..4db475e674 --- /dev/null +++ b/bundle/direct/dresources/resources.generated.yml @@ -0,0 +1,146 @@ +# Generated, do not edit. API field behaviors from OpenAPI schema. +# +# For manual edits and schema description, see resources.yml. + +resources: + + alerts: + + ignore_remote_changes: + # OUTPUT_ONLY: + - create_time + - effective_run_as + - evaluation.last_evaluated_at + - evaluation.state + - id + - lifecycle_state + - owner_user_name + - update_time + + apps: + + ignore_remote_changes: + # OUTPUT_ONLY: + - active_deployment + - app_status + - compute_status + - create_time + - creator + - default_source_code_path + - effective_budget_policy_id + - effective_usage_policy_id + - effective_user_api_scopes + - id + - oauth2_app_client_id + - oauth2_app_integration_id + - pending_deployment + - service_principal_client_id + - service_principal_id + - service_principal_name + - update_time + - updater + - url + + # clusters: no api field behaviors + + dashboards: + + recreate_on_changes: + # IMMUTABLE: + - parent_path + + ignore_remote_changes: + # OUTPUT_ONLY: + - create_time + - dashboard_id + - lifecycle_state + - path + - update_time + + database_catalogs: + + ignore_remote_changes: + # INPUT_ONLY: + - create_database_if_not_exists + + # OUTPUT_ONLY: + - uid + + database_instances: + + recreate_on_changes: + # IMMUTABLE: + - parent_instance_ref + + ignore_remote_changes: + # INPUT_ONLY: + - custom_tags + - enable_pg_native_login + - enable_readable_secondaries + - node_count + - parent_instance_ref.lsn + - retention_window_in_days + - stopped + - usage_policy_id + + # OUTPUT_ONLY: + - child_instance_refs + - creation_time + - creator + - effective_capacity + - effective_custom_tags + - effective_enable_pg_native_login + - effective_enable_readable_secondaries + - effective_node_count + - effective_retention_window_in_days + - effective_stopped + - effective_usage_policy_id + - parent_instance_ref.effective_lsn + - parent_instance_ref.uid + - pg_version + - read_only_dns + - read_write_dns + - state + - uid + + # experiments: no api field behaviors + + # jobs: no api field behaviors + + # model_serving_endpoints: no api field behaviors + + # models: no api field behaviors + + pipelines: + + ignore_remote_changes: + # OUTPUT_ONLY: + - ingestion_definition.source_type + + # quality_monitors: no api field behaviors + + # registered_models: no api field behaviors + + # schemas: no api field behaviors + + # secret_scopes: no api field behaviors + + # sql_warehouses: no api field behaviors + + synced_database_tables: + + ignore_remote_changes: + # INPUT_ONLY: + - database_instance_name + - logical_database_name + - spec.create_database_objects_if_missing + - spec.existing_pipeline_id + - spec.new_pipeline_spec + + # OUTPUT_ONLY: + - data_synchronization_status + - effective_database_instance_name + - effective_logical_database_name + - unity_catalog_provisioning_state + + # volumes: no api field behaviors diff --git a/bundle/direct/dresources/resources.yml b/bundle/direct/dresources/resources.yml index f173b1ddff..8803c0be7a 100644 --- a/bundle/direct/dresources/resources.yml +++ b/bundle/direct/dresources/resources.yml @@ -8,7 +8,6 @@ # ignore_local_changes: fields where local changes are ignored (can't be updated via API) resources: - # jobs: no special config pipelines: recreate_on_changes: @@ -96,11 +95,7 @@ resources: update_id_on_changes: - name - # clusters: no special config - dashboards: - recreate_on_changes: - - parent_path ignore_remote_changes: # "serialized_dashboard" locally and remotely will have different contents @@ -129,20 +124,3 @@ resources: # When scope name changes, we need UpdateWithID trigger. This is necessary so that subsequent # DoRead operations use the correct ID and we do not end up with a persistent drift. - scope_name - - # alerts: no special config - - # sql_warehouses: no special config - - # database_instances: no special config - - database_catalogs: - ignore_remote_changes: - # Backend does not set this: - - create_database_if_not_exists - - synced_database_tables: - ignore_remote_changes: - # Backend does not set these fields in response (it sets effective_ counterparts instead) - - database_instance_name - - logical_database_name diff --git a/bundle/direct/tools/generate_apitypes.py b/bundle/direct/tools/generate_apitypes.py new file mode 100644 index 0000000000..78815eae0e --- /dev/null +++ b/bundle/direct/tools/generate_apitypes.py @@ -0,0 +1,111 @@ +#!/usr/bin/env python3 +""" +Find candidate types from openapi.json that match resource shapes in out.fields.txt and save them (apitypes.generated.yml). + +The types are found based on top level field overlap between bundle schema and API type. + +Note, we could search for exact type but that may not always work if we create a custom type or embed SDK type. +""" + +import argparse +import json +import re +import sys +from collections import Counter +from pathlib import Path + + +def parse_out_fields(path): + """Parse out.fields.txt to extract top-level STATE field names per resource.""" + resource_fields = {} + + for line in path.read_text().splitlines(): + parts = line.split("\t") + if len(parts) < 3 or not parts[0].startswith("resources."): + continue + + field_path, flags = parts[0], parts[2:] + if "STATE" not in flags and "ALL" not in flags: + continue + + # Field line: resources..*. + match = re.match(r"resources\.([a-z_]+)\.\*\.([a-z_]+)$", field_path) + if match: + resource_fields.setdefault(match.group(1), set()).add(match.group(2)) + + return resource_fields + + +def get_schema_fields(schemas): + """Get top-level field names for each schema type.""" + schema_fields = {} + for name, schema in schemas.items(): + props = schema.get("properties", {}) + if props: + schema_fields[name] = set(props.keys()) + return schema_fields + + +def main(): + parser = argparse.ArgumentParser(description="Generate apitypes.yml from OpenAPI schema") + parser.add_argument("apischema", type=Path, help="Path to OpenAPI schema JSON file") + parser.add_argument("out_fields", type=Path, help="Path to out.fields.txt file") + args = parser.parse_args() + + resource_fields = parse_out_fields(args.out_fields) + schemas = json.loads(args.apischema.read_text()).get("components", {}).get("schemas", {}) + schema_fields = get_schema_fields(schemas) + + field_counts = Counter() + for fields in schema_fields.values(): + field_counts.update(fields) + + field_weights = {f: 1.0 / c for f, c in field_counts.items()} + + # Fields to ignore in resource definitions (handled separately) + ignore_resource_fields = {"permissions", "grants"} + + top_matches = {} + + for resource in sorted(resource_fields): + res_fields = resource_fields[resource] - ignore_resource_fields + max_score = sum(field_weights.get(f, 1.0) for f in res_fields) + + # Find matching schema types + candidates = [] + for schema_name, s_fields in schema_fields.items(): + overlap = res_fields & s_fields + if not overlap: + continue + match_score = sum(field_weights.get(f, 1.0) for f in overlap) + extra_resource = len(res_fields - s_fields) + extra_schema = len(s_fields - res_fields) + score = match_score - 0.0001 * (extra_resource + extra_schema) + pct = score / max_score * 100 if max_score > 0 else 0 + candidates.append((pct, score, schema_name, overlap, s_fields)) + + candidates.sort(reverse=True) + if candidates: + top_matches[resource] = candidates[0][2] + + print(f"\n{resource}: {len(res_fields)} fields, max_score={max_score:.2f}", file=sys.stderr) + top_pct = candidates[0][0] if candidates else 0 + for pct, score, schema_name, overlap, s_fields in candidates[:5]: + # Only show if >= 80% or within 20% of top entry + if pct < 80 and (top_pct - pct) >= 20: + continue + missing = res_fields - s_fields + extra = s_fields - res_fields + print(f" {pct:5.1f}% {schema_name}", file=sys.stderr) + print(f" # matching: {sorted(overlap)}", file=sys.stderr) + print(f" # in_resource_only: {sorted(missing)}", file=sys.stderr) + print(f" # in_schema_only: {sorted(extra)}", file=sys.stderr) + + print("# Generated, do not edit. Override via apitypes.yml") + for resource in sorted(top_matches): + print("") + print(f"{resource}: {top_matches[resource]}") + + +if __name__ == "__main__": + main() diff --git a/bundle/direct/tools/generate_resources.py b/bundle/direct/tools/generate_resources.py new file mode 100644 index 0000000000..be02d6cb19 --- /dev/null +++ b/bundle/direct/tools/generate_resources.py @@ -0,0 +1,165 @@ +#!/usr/bin/env python3 +""" +Generate resources.generated.yml from OpenAPI schema field behaviors. +""" + +import argparse +import json +import re +import sys +from pathlib import Path + +import yaml + + +def parse_apitypes(path): + """Parse apitypes.generated.yml to get resource types.""" + data = yaml.safe_load(path.read_text()) + return {resource: type_name for resource, type_name in data.items() if type_name} + + +def parse_out_fields(path): + """Parse out.fields.txt to extract STATE field names per resource.""" + state_fields = {} + + for line in path.read_text().splitlines(): + parts = line.split("\t") + if len(parts) < 3 or not parts[0].startswith("resources."): + continue + + field_path, flags = parts[0], parts[2:] + if "STATE" not in flags and "ALL" not in flags: + continue + + # Field line: resources..*. + match = re.match(r"resources\.([a-z_]+)\.\*\.(.+)", field_path) + if match and "[*]" not in match.group(2): + state_fields.setdefault(match.group(1), set()).add(match.group(2)) + + return state_fields + + +def get_field_behaviors(schemas, type_name): + """Extract all field behaviors from a schema.""" + if type_name not in schemas: + return {} + + def extract(schema, prefix, visited, depth): + if depth > 4: + return {} + results = {} + for name, prop in schema.get("properties", {}).items(): + path = f"{prefix}.{name}" if prefix else name + behaviors = prop.get("x-databricks-field-behaviors", []) + if prop.get("x-databricks-immutable") and "IMMUTABLE" not in behaviors: + behaviors.append("IMMUTABLE") + + if behaviors: + results[path] = behaviors + + if "$ref" in prop: + ref = prop["$ref"].split("/")[-1] + if ref in schemas and ref not in visited: + visited.add(ref) + results.update(extract(schemas[ref], path, visited, depth + 1)) + return results + + return extract(schemas[type_name], "", set(), 0) + + +def filter_prefixes(fields): + """Remove fields that are children of other fields in the list.""" + result = [] + for field, behavior in sorted(fields): + if not any(field.startswith(f + ".") for f, _ in result): + result.append((field, behavior)) + return result + + +def write_field_group(lines, header, fields): + """Write a group of fields with behavior type comments.""" + lines.append(f"\n {header}:") + by_behavior = {} + for field, behavior in fields: + by_behavior.setdefault(behavior, []).append(field) + first = True + for behavior in sorted(by_behavior): + if not first: + lines.append("") + first = False + lines.append(f" # {behavior}:") + for field in by_behavior[behavior]: + lines.append(f" - {field}") + + +def generate(resource_behaviors): + """Generate resources.yml.""" + lines = [ + """# Generated, do not edit. API field behaviors from OpenAPI schema. +# +# For manual edits and schema description, see resources.yml. + +resources:""" + ] + + for resource in sorted(resource_behaviors): + behaviors = resource_behaviors[resource] + + ignore_remote, recreate = [], [] + for field, fb in sorted(behaviors.items()): + if "OUTPUT_ONLY" in fb: + ignore_remote.append((field, "OUTPUT_ONLY")) + elif "INPUT_ONLY" in fb: + ignore_remote.append((field, "INPUT_ONLY")) + if "IMMUTABLE" in fb: + recreate.append((field, "IMMUTABLE")) + + ignore_remote = filter_prefixes(ignore_remote) + recreate = filter_prefixes(recreate) + + if not ignore_remote and not recreate: + lines.append(f"\n # {resource}: no api field behaviors") + continue + + lines.append(f"\n {resource}:") + + if recreate: + write_field_group(lines, "recreate_on_changes", recreate) + + if ignore_remote: + write_field_group(lines, "ignore_remote_changes", ignore_remote) + + while lines and lines[-1] == "": + lines.pop() + + return "\n".join(lines) + + +def main(): + parser = argparse.ArgumentParser(description="Generate resources YAML from OpenAPI schema") + parser.add_argument("apischema", type=Path, help="Path to OpenAPI schema JSON file") + parser.add_argument("apitypes", type=Path, help="Path to apitypes.generated.yml file") + # TODO: add non-generated apitypes.yml here once the need to override generated ones arises + parser.add_argument("out_fields", type=Path, help="Path to out.fields.txt file") + args = parser.parse_args() + + resource_types = parse_apitypes(args.apitypes) + state_fields = parse_out_fields(args.out_fields) + schemas = json.loads(args.apischema.read_text()).get("components", {}).get("schemas", {}) + + resource_behaviors = {} + for resource, type_name in sorted(resource_types.items()): + fields = state_fields.get(resource, set()) + print(f"\n{resource}: type={type_name}", file=sys.stderr) + all_behaviors = get_field_behaviors(schemas, type_name) + if all_behaviors: + print(f" field behaviors from {type_name}:", file=sys.stderr) + for field in sorted(all_behaviors): + print(f" {field}: {all_behaviors[field]}", file=sys.stderr) + resource_behaviors[resource] = {f: b for f, b in all_behaviors.items() if f in fields} + + print(generate(resource_behaviors)) + + +if __name__ == "__main__": + main()