diff --git a/AGENTS.md b/AGENTS.md index d87c286..ff3c32b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,7 +15,7 @@ The core ownership boundaries are: as the generic known non-sensitive env string type in the store cutover. - `internal/projection/dotenv`: dotenv projection, legacy `.env.example` comment parsing, materialization, and dotenv rendering. -- `internal/store`: target home for the v2 store lifecycle. +- `internal/state`: EffectiveState machine, projections, and envelopes. - `pkg/owl`: small public API. Export what callers need; do not re-export every internal model detail by default. - `cmd`: command wiring and rendering only. @@ -93,6 +93,35 @@ Avoid: old v1 GraphQL-backed runtime as a second owner of state semantics ``` +## Graph Facade Layering + +Keep Owl operation facades layered deliberately: + +- Friendly API: typed Owl input to typed Owl output. Normal Go callers should + use methods such as `Store.Snapshot`, `Store.Source`, `Store.Check`, + `Store.Resolve`, and `Store.ApplyPromptAnswers`. +- Operation builders: typed Owl input to canonical GraphQL document plus + schema-shaped variables. Builders such as `BuildSnapshotOperation` should be + pure and useful for tests, bindings, and debug tooling. +- Graph escape hatch: caller-provided GraphQL document plus variables to raw + graph result. Expose as an advanced/debug path such as `ExecuteGraphQL`, not + as the normal CLI, Extension, or Runme API. + +Edges own I/O. CLI, Extension, Runme gRPC, and resolver/provider code should +read files, process env, protobuf streams, prompts, and external systems before +calling Owl. Graph execution receives already-materialized bytes/strings and +typed variables; it should not open project files, read process env, prompt +users, call secret managers, or speak gRPC directly. + +`cmd/` is a public consumer. CLI code may use `pkg/...` APIs and command-local +wiring only; it must not import `github.com/runmedev/owl/internal/...` +packages. Move needed behavior behind `pkg/owl` or `pkg/owl/seed` instead. + +Normal command/public paths must not call legacy helper shapes such as +`SnapshotItems`, `Dotenv(policy)`, `CheckState`, `LoadDotenv`, +`LoadDotenvLines`, or legacy `Update`/`Delete` helpers. Use typed graph-backed +facades such as `Snapshot`, `Source`, `Check`, and `ApplyUpdate`. + ## Store Cutover Decisions - Implement the cutover as one cohesive PR with focused commits, not separate diff --git a/Makefile b/Makefile index c50464b..2021f73 100644 --- a/Makefile +++ b/Makefile @@ -4,9 +4,9 @@ GIT_SHA := $(shell git rev-parse HEAD) DATE := $(shell date -u +"%Y-%m-%dT%H:%M:%SZ") VERSION := $(shell git describe --tags --match 'v[0-9]*' --always --dirty) LDFLAGS := -s -w \ - -X 'github.com/runmedev/owl/internal/version.BuildDate=$(DATE)' \ - -X 'github.com/runmedev/owl/internal/version.BuildVersion=$(subst v,,$(VERSION))' \ - -X 'github.com/runmedev/owl/internal/version.Commit=$(GIT_SHA)' + -X 'github.com/runmedev/owl/pkg/owl.BuildDate=$(DATE)' \ + -X 'github.com/runmedev/owl/pkg/owl.BuildVersion=$(subst v,,$(VERSION))' \ + -X 'github.com/runmedev/owl/pkg/owl.Commit=$(GIT_SHA)' .PHONY: build build: BUILD_OUTPUT ?= owl diff --git a/cmd/cue_catalog.go b/cmd/cue_catalog.go index 71b4760..057b769 100644 --- a/cmd/cue_catalog.go +++ b/cmd/cue_catalog.go @@ -4,22 +4,16 @@ import ( "fmt" "os" - "github.com/runmedev/owl/internal/registry" + "github.com/runmedev/owl/pkg/owl" ) const cueRootEnv = "OWL_CUE_ROOT" var lookupEnv = os.LookupEnv -func commandTypeProvider() (registry.TypeProvider, error) { - root, configured := lookupEnv(cueRootEnv) - if !configured { - return registry.NewBuiltInRegistry(), nil - } - if root == "" { - return nil, fmt.Errorf("%s is set but empty", cueRootEnv) - } - types, err := registry.NewBuiltInRegistryFromDirectory(root) +func commandTypeProvider() (owl.TypeProvider, error) { + root, _ := lookupEnv(cueRootEnv) + types, err := owl.TypeProviderFromCatalogInput(owl.TypeCatalogInput{Root: root}) if err != nil { return nil, fmt.Errorf("load CUE catalog from %s=%q: %w", cueRootEnv, root, err) } diff --git a/cmd/cue_catalog_test.go b/cmd/cue_catalog_test.go index 7691360..a670edc 100644 --- a/cmd/cue_catalog_test.go +++ b/cmd/cue_catalog_test.go @@ -11,9 +11,8 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/runmedev/owl/internal/model" - "github.com/runmedev/owl/internal/registry" - "github.com/runmedev/owl/internal/seed" + "github.com/runmedev/owl/pkg/owl" + "github.com/runmedev/owl/pkg/owl/seed" ) func TestCommandCUECatalogPrecedence(t *testing.T) { @@ -51,11 +50,9 @@ func TestCommandCUECatalogPrecedence(t *testing.T) { require.Error(t, runCheck(t)) }) - t.Run("empty is rejected", func(t *testing.T) { + t.Run("empty uses embedded", func(t *testing.T) { withLookupEnv(t, func(string) (string, bool) { return "", true }) - err := runCheck(t) - require.Error(t, err) - assert.Contains(t, err.Error(), "OWL_CUE_ROOT is set but empty") + require.NoError(t, runCheck(t)) }) t.Run("invalid never falls back", func(t *testing.T) { @@ -91,7 +88,7 @@ func TestCUERootControlVariableIsNotObserved(t *testing.T) { func TestProjectSpecReceivesSelectedTypeProvider(t *testing.T) { t.Parallel() - provider := &trackingTypeProvider{BuiltInRegistry: registry.NewBuiltInRegistry()} + provider := &trackingTypeProvider{TypeProvider: owl.NewBuiltInTypeProvider()} client := NewLocalStoreClient(LocalStoreOptions{TypeProvider: provider}) result, err := client.ProjectSpec(context.Background(), ProjectSpecRequest{ ConfigPath: filepath.Join(commandRepoRoot(t), "examples/redis/owl.toml"), @@ -102,13 +99,13 @@ func TestProjectSpecReceivesSelectedTypeProvider(t *testing.T) { } type trackingTypeProvider struct { - registry.BuiltInRegistry + owl.TypeProvider resolveTypeRefs int } -func (p *trackingTypeProvider) ResolveTypeRef(ref string) (model.TypeDef, bool, error) { +func (p *trackingTypeProvider) ResolveTypeRef(ref string) (owl.TypeDef, bool, error) { p.resolveTypeRefs++ - return p.BuiltInRegistry.ResolveTypeRef(ref) + return p.TypeProvider.ResolveTypeRef(ref) } func withLookupEnv(t *testing.T, fn func(string) (string, bool)) { diff --git a/cmd/layering_test.go b/cmd/layering_test.go new file mode 100644 index 0000000..972eeda --- /dev/null +++ b/cmd/layering_test.go @@ -0,0 +1,66 @@ +package cmd + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestCommandLayeringUsesPublicPackages(t *testing.T) { + t.Parallel() + + internalImport := `"github.com/runmedev/owl/` + `internal/` + for _, file := range goFiles(t, ".") { + raw, err := os.ReadFile(file) + require.NoError(t, err) + require.NotContains(t, string(raw), internalImport, file) + } +} + +func TestCommandAndSeedAvoidLegacyStoreHelpers(t *testing.T) { + t.Parallel() + + forbidden := []string{ + ".SnapshotItems(", + ".CheckState(", + ".LoadDotenv(", + ".LoadDotenvLines(", + "store.Update(", + "store.Delete(", + "roundTripped.Update(", + "roundTripped.Delete(", + } + for _, root := range []string{".", "../internal/seed"} { + for _, file := range goFiles(t, root) { + if strings.HasSuffix(file, "_test.go") { + continue + } + raw, err := os.ReadFile(file) + require.NoError(t, err) + for _, pattern := range forbidden { + require.NotContains(t, string(raw), pattern, file) + } + } + } +} + +func goFiles(t *testing.T, root string) []string { + t.Helper() + var files []string + require.NoError(t, filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + return nil + } + if strings.HasSuffix(path, ".go") { + files = append(files, path) + } + return nil + })) + return files +} diff --git a/cmd/local.go b/cmd/local.go index 799b000..ccfed4f 100644 --- a/cmd/local.go +++ b/cmd/local.go @@ -8,11 +8,8 @@ import ( "github.com/spf13/cobra" - "github.com/runmedev/owl/internal/model" - "github.com/runmedev/owl/internal/registry" - "github.com/runmedev/owl/internal/requirements" - "github.com/runmedev/owl/internal/seed" "github.com/runmedev/owl/pkg/owl" + "github.com/runmedev/owl/pkg/owl/seed" ) type LocalStoreOptions struct { @@ -23,7 +20,7 @@ type LocalStoreOptions struct { Direnv seed.DirenvPolicy DirenvDir string DirenvRunner seed.DirenvExportRunner - TypeProvider registry.TypeProvider + TypeProvider owl.TypeProvider } type LocalStoreClient struct { @@ -82,12 +79,15 @@ func (c *LocalStoreClient) Snapshot(ctx context.Context, req SnapshotRequest) (* return nil, err } - items, err := store.Snapshot(owl.SnapshotPolicy{Reveal: req.Reveal && req.Insecure}) + snapshot, err := store.Snapshot(ctx, owl.SnapshotInput{ + Policy: owl.SnapshotPolicy{Reveal: req.Reveal && req.Insecure}, + Filter: owl.SnapshotFilter{All: req.All, Limit: req.Limit}, + }) if err != nil { return nil, err } - return &SnapshotResult{Envs: snapshotEnvsFromItems(items)}, nil + return &SnapshotResult{Envs: snapshotEnvsFromItems(snapshot.Envs)}, nil } func (c *LocalStoreClient) Source(ctx context.Context, req SourceRequest) (*SourceResult, error) { @@ -96,12 +96,14 @@ func (c *LocalStoreClient) Source(ctx context.Context, req SourceRequest) (*Sour return nil, err } - envs, err := store.Dotenv(owl.DotenvPolicy{Insecure: req.Insecure}) + source, err := store.Source(ctx, owl.SourceInput{ + Policy: owl.DotenvPolicy{Insecure: req.Insecure}, + }) if err != nil { return nil, err } - return &SourceResult{Envs: envs}, nil + return &SourceResult{Envs: source.Envs}, nil } func (c *LocalStoreClient) Check(ctx context.Context, req CheckRequest) (*CheckResult, error) { @@ -110,19 +112,18 @@ func (c *LocalStoreClient) Check(ctx context.Context, req CheckRequest) (*CheckR return nil, err } - check := store.Check() - items, err := store.Snapshot(owl.SnapshotPolicy{}) + check, err := store.Check(ctx, owl.CheckInput{}) if err != nil { return nil, err } return &CheckResult{ OK: check.OK, Diagnostics: append(diagnosticStrings(c.lastSourceDiagnostics, req.Details), diagnosticStrings(check.Diagnostics, req.Details)...), - Checked: len(items), + Checked: check.Checked, }, nil } -func (c *LocalStoreClient) Type(_ context.Context, req TypeRequest) (*TypeResult, error) { +func (c *LocalStoreClient) Type(ctx context.Context, req TypeRequest) (*TypeResult, error) { if req.SpecPath == "" { req.SpecPath = ".env.spec" } @@ -133,7 +134,7 @@ func (c *LocalStoreClient) Type(_ context.Context, req TypeRequest) (*TypeResult return nil, err } - result, err := store.Type(owl.TypePolicy{All: req.All}) + result, err := store.Type(ctx, owl.TypeInput{Policy: owl.TypePolicy{All: req.All}}) if err != nil { return nil, err } @@ -263,37 +264,37 @@ func processEnvForOptions(options LocalStoreOptions) []string { return filtered } -func (c *LocalStoreClient) ProjectSpec(_ context.Context, req ProjectSpecRequest) (*ProjectSpecResult, error) { +func (c *LocalStoreClient) ProjectSpec(ctx context.Context, req ProjectSpecRequest) (*ProjectSpecResult, error) { configPath, err := resolveConfigPath(req.ConfigPath, true) if err != nil { return nil, err } - input, err := requirements.ReadConfigFile(configPath) - if err != nil { - return nil, err - } - storeOptions := []owl.StoreOption{owl.WithConfigSource(configPath, input)} - if c.options.TypeProvider != nil { - storeOptions = append(storeOptions, owl.WithTypeProvider(c.options.TypeProvider)) + types := c.options.TypeProvider + if types == nil { + types = owl.NewBuiltInTypeProvider() } + storeOptions := []owl.StoreOption{owl.WithTypeProvider(types), owl.WithConfigFile(configPath)} store, err := owl.NewStore(storeOptions...) if err != nil { return nil, err } - rendered, err := store.DotenvSpec() + rendered, err := store.ProjectSpec(ctx, owl.ProjectSpecInput{ + Load: owl.LoadInput{}, + }) if err != nil { return nil, err } + outputText := rendered.Rendered output := req.Output if req.Write { output = ".env.spec" } if output != "" && output != "-" { - if err := writeGeneratedDotenvSpec(output, rendered); err != nil { + if err := writeGeneratedDotenvSpec(output, outputText); err != nil { return nil, err } } - return &ProjectSpecResult{Rendered: rendered}, nil + return &ProjectSpecResult{Rendered: outputText}, nil } func resolveConfigPath(explicit string, required bool) (string, error) { @@ -336,7 +337,7 @@ func writeGeneratedDotenvSpec(path string, rendered string) error { } func isGeneratedDotenvSpec(raw []byte) bool { - return strings.HasPrefix(string(raw), requirements.GeneratedDotenvSpecHeaderPrefix) + return strings.HasPrefix(string(raw), owl.GeneratedDotenvSpecHeaderPrefix) } func renderDotenvSpecTypeProposals(proposals []owl.TypeProposal) string { @@ -529,7 +530,7 @@ func resolveResultFromOwl(result owl.ResolveResult) *ResolveResult { ProjectionKey: string(action.Prompt.ProjectionKey), Label: action.Prompt.Label, Description: action.Prompt.Description, - Sensitive: action.Prompt.Sensitivity == model.SensitivitySensitive, + Sensitive: action.Prompt.Sensitivity == owl.SensitivitySensitive, Required: action.Prompt.Required, AllowEmpty: action.Prompt.AllowEmpty, } diff --git a/cmd/project_test.go b/cmd/project_test.go index 5eb491b..235f720 100644 --- a/cmd/project_test.go +++ b/cmd/project_test.go @@ -12,7 +12,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/runmedev/owl/internal/requirements" + "github.com/runmedev/owl/pkg/owl" ) func TestProjectSpecRendersGeneratedSpec(t *testing.T) { @@ -146,9 +146,9 @@ needs: instance: default `), 0o600)) - jsonConfig, err := requirements.ReadConfigFile(jsonPath) + jsonConfig, err := owl.ReadConfigFile(jsonPath) require.NoError(t, err) - yamlConfig, err := requirements.ReadConfigFile(yamlPath) + yamlConfig, err := owl.ReadConfigFile(yamlPath) require.NoError(t, err) require.Len(t, jsonConfig.Needs, 1) diff --git a/cmd/root.go b/cmd/root.go index 9e72fc9..2faab65 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -5,7 +5,7 @@ import ( "github.com/spf13/cobra" - "github.com/runmedev/owl/internal/version" + "github.com/runmedev/owl/pkg/owl" ) var errSilentExit = stderrors.New("silent exit") @@ -18,7 +18,7 @@ func NewRootCommand() *cobra.Command { cmd := cobra.Command{ Use: "owl", Short: "Typed environment variable store", - Version: version.BaseVersionInfo(), + Version: owl.BaseVersionInfo(), SilenceErrors: true, SilenceUsage: true, } diff --git a/internal/graph/input_descriptor.go b/internal/graph/input_descriptor.go new file mode 100644 index 0000000..6b0cf49 --- /dev/null +++ b/internal/graph/input_descriptor.go @@ -0,0 +1,23 @@ +package graph + +import "github.com/graphql-go/graphql" + +type graphInputField struct { + name string + typ graphql.Input +} + +func graphInput(name string, typ graphql.Input) graphInputField { + return graphInputField{name: name, typ: typ} +} + +func graphInputObject(name string, fields ...graphInputField) *graphql.InputObject { + fieldMap := make(graphql.InputObjectConfigFieldMap, len(fields)) + for _, field := range fields { + fieldMap[field.name] = &graphql.InputObjectFieldConfig{Type: field.typ} + } + return graphql.NewInputObject(graphql.InputObjectConfig{ + Name: name, + Fields: fieldMap, + }) +} diff --git a/internal/graph/plan.go b/internal/graph/plan.go index bb3d03d..7c014fe 100644 --- a/internal/graph/plan.go +++ b/internal/graph/plan.go @@ -7,7 +7,7 @@ import ( "github.com/graphql-go/graphql/language/ast" "github.com/graphql-go/graphql/language/printer" - "github.com/runmedev/owl/internal/store" + "github.com/runmedev/owl/internal/state" ) type plannedQuery struct { @@ -16,7 +16,7 @@ type plannedQuery struct { Path []string } -func planStateEnvelopeQuery(records []store.OperationRecord) (plannedQuery, error) { +func planStateEnvelopeQuery(records []state.OperationRecord) (plannedQuery, error) { if len(records) == 0 { return plannedQuery{}, errors.New("operation plan is empty") } @@ -30,7 +30,7 @@ func planStateEnvelopeQuery(records []store.OperationRecord) (plannedQuery, erro for index, record := range records { next := ast.NewSelectionSet(&ast.SelectionSet{}) switch record.Kind { - case store.OperationRecordLoad: + case state.OperationRecordLoad: name := fmt.Sprintf("load_%d", index) varDefs = append(varDefs, variableDefinition(name, nonNull(namedType("LoadInput")))) input := record.Load @@ -40,10 +40,10 @@ func planStateEnvelopeQuery(records []store.OperationRecord) (plannedQuery, erro argument("input", variable(name)), }, next)) path = append(path, "load") - case store.OperationRecordUpdate: + case state.OperationRecordUpdate: name := fmt.Sprintf("update_%d", index) varDefs = append(varDefs, variableDefinition(name, namedType("DotenvInput"))) - vars[name] = marshalDotenvInput(store.LoadInput{ + vars[name] = marshalDotenvInput(state.LoadInput{ DotenvSource: record.Update.Source, Dotenv: record.Update.Dotenv, Timestamp: record.Timestamp, @@ -52,7 +52,7 @@ func planStateEnvelopeQuery(records []store.OperationRecord) (plannedQuery, erro argument("dotenv", variable(name)), }, next)) path = append(path, "update") - case store.OperationRecordDelete: + case state.OperationRecordDelete: name := fmt.Sprintf("delete_%d", index) varDefs = append(varDefs, variableDefinition(name, list(nonNull(namedType("String"))))) vars[name] = append([]string{}, record.Delete.Keys...) @@ -60,7 +60,7 @@ func planStateEnvelopeQuery(records []store.OperationRecord) (plannedQuery, erro argument("keys", variable(name)), }, next)) path = append(path, "delete") - case store.OperationRecordResolverAttempt: + case state.OperationRecordResolverAttempt: name := fmt.Sprintf("resolverAttempt_%d", index) varDefs = append(varDefs, variableDefinition(name, nonNull(namedType("ResolverAttemptInput")))) vars[name] = marshalResolverAttempt(record.ResolverAttempt) @@ -68,7 +68,7 @@ func planStateEnvelopeQuery(records []store.OperationRecord) (plannedQuery, erro argument("attempt", variable(name)), }, next)) path = append(path, "recordResolverAttempt") - case store.OperationRecordApplyResolverProposal: + case state.OperationRecordApplyResolverProposal: name := fmt.Sprintf("resolverProposal_%d", index) varDefs = append(varDefs, variableDefinition(name, nonNull(namedType("ResolverProposalInput")))) timestampName := fmt.Sprintf("resolverProposalTimestamp_%d", index) diff --git a/internal/graph/runtime.go b/internal/graph/runtime.go index e59344d..61fdfd5 100644 --- a/internal/graph/runtime.go +++ b/internal/graph/runtime.go @@ -11,7 +11,7 @@ import ( "github.com/runmedev/owl/internal/model" "github.com/runmedev/owl/internal/registry" "github.com/runmedev/owl/internal/resolver" - "github.com/runmedev/owl/internal/store" + "github.com/runmedev/owl/internal/state" ) type Runtime struct { @@ -19,26 +19,40 @@ type Runtime struct { types registry.TypeProvider } -type Context struct { +type GraphContext struct { State model.EffectiveState Types registry.TypeProvider } -type LoadInput = store.LoadInput +type LoadInput = state.LoadInput -type SnapshotPolicy = store.SnapshotPolicy +type SnapshotPolicy = state.SnapshotPolicy -type DotenvPolicy = store.DotenvPolicy +type DotenvPolicy = state.DotenvPolicy -type GetPolicy = store.GetPolicy +type GetPolicy = state.GetPolicy -type SnapshotItem = store.SnapshotItem +type TypePolicy = state.TypePolicy -type GetResult = store.GetResult +type SnapshotItem = state.SnapshotItem -type CheckResult = store.CheckResult +type GetResult = state.GetResult -type StateEnvelope = store.StateEnvelope +type TypeResult = state.TypeResult + +type CheckResult = state.CheckResult + +type StateEnvelope = state.StateEnvelope + +type Operation struct { + Name string + Document string + Variables map[string]interface{} +} + +type ExecuteResult struct { + Data json.RawMessage +} var traceGraphQLQuery func(query string, vars map[string]interface{}) @@ -56,10 +70,8 @@ func NewRuntime(types registry.TypeProvider) (*Runtime, error) { } func (r *Runtime) Snapshot(ctx context.Context, input LoadInput, policy SnapshotPolicy) ([]SnapshotItem, error) { - result, err := r.do(ctx, snapshotQuery, map[string]interface{}{ - "input": marshalInput(input), - "reveal": policy.Reveal, - }) + op := SnapshotOperation(input, policy) + result, err := r.do(ctx, op.Document, op.Variables) if err != nil { return nil, err } @@ -70,11 +82,20 @@ func (r *Runtime) Snapshot(ctx context.Context, input LoadInput, policy Snapshot return decodeSnapshot(raw), nil } +func SnapshotOperation(input LoadInput, policy SnapshotPolicy) Operation { + return Operation{ + Name: "OwlSnapshot", + Document: snapshotQuery, + Variables: map[string]interface{}{ + "input": marshalInput(input), + "reveal": policy.Reveal, + }, + } +} + func (r *Runtime) Dotenv(ctx context.Context, input LoadInput, policy DotenvPolicy) ([]string, error) { - result, err := r.do(ctx, dotenvQuery, map[string]interface{}{ - "input": marshalInput(input), - "insecure": policy.Insecure, - }) + op := DotenvOperation(input, policy) + result, err := r.do(ctx, op.Document, op.Variables) if err != nil { return nil, err } @@ -89,12 +110,20 @@ func (r *Runtime) Dotenv(ctx context.Context, input LoadInput, policy DotenvPoli return envs, nil } +func DotenvOperation(input LoadInput, policy DotenvPolicy) Operation { + return Operation{ + Name: "OwlDotenv", + Document: dotenvQuery, + Variables: map[string]interface{}{ + "input": marshalInput(input), + "insecure": policy.Insecure, + }, + } +} + func (r *Runtime) Get(ctx context.Context, input LoadInput, key string, policy GetPolicy) (GetResult, bool, error) { - result, err := r.do(ctx, getQuery, map[string]interface{}{ - "input": marshalInput(input), - "key": key, - "reveal": policy.Reveal, - }) + op := GetOperation(input, key, policy) + result, err := r.do(ctx, op.Document, op.Variables) if err != nil { return GetResult{}, false, err } @@ -108,10 +137,21 @@ func (r *Runtime) Get(ctx context.Context, input LoadInput, key string, policy G return decodeGet(raw), true, nil } +func GetOperation(input LoadInput, key string, policy GetPolicy) Operation { + return Operation{ + Name: "OwlGet", + Document: getQuery, + Variables: map[string]interface{}{ + "input": marshalInput(input), + "key": key, + "reveal": policy.Reveal, + }, + } +} + func (r *Runtime) SensitiveKeys(ctx context.Context, input LoadInput) ([]string, error) { - result, err := r.do(ctx, sensitiveKeysQuery, map[string]interface{}{ - "input": marshalInput(input), - }) + op := SensitiveKeysOperation(input) + result, err := r.do(ctx, op.Document, op.Variables) if err != nil { return nil, err } @@ -122,13 +162,93 @@ func (r *Runtime) SensitiveKeys(ctx context.Context, input LoadInput) ([]string, return decodeStringList(raw), nil } +func SensitiveKeysOperation(input LoadInput) Operation { + return Operation{ + Name: "OwlSensitiveKeys", + Document: sensitiveKeysQuery, + Variables: map[string]interface{}{ + "input": marshalInput(input), + }, + } +} + +func (r *Runtime) DotenvSpec(ctx context.Context, input LoadInput) (string, error) { + op := DotenvSpecOperation(input) + result, err := r.do(ctx, op.Document, op.Variables) + if err != nil { + return "", err + } + raw, err := extractPath(result.Data, "Environment", "load", "normalize", "validate", "render", "dotenvSpec") + if err != nil { + return "", err + } + return stringValue(raw), nil +} + +func DotenvSpecOperation(input LoadInput) Operation { + return Operation{ + Name: "OwlDotenvSpec", + Document: dotenvSpecQuery, + Variables: map[string]interface{}{ + "input": marshalInput(input), + }, + } +} + +func (r *Runtime) ProjectSpec(ctx context.Context, input LoadInput) (string, error) { + op := ProjectSpecOperation(input) + result, err := r.do(ctx, op.Document, op.Variables) + if err != nil { + return "", err + } + raw, err := extractPath(result.Data, "Environment", "load", "normalize", "validate", "render", "dotenvSpec") + if err != nil { + return "", err + } + return stringValue(raw), nil +} + +func ProjectSpecOperation(input LoadInput) Operation { + return Operation{ + Name: "OwlProjectSpec", + Document: projectSpecQuery, + Variables: map[string]interface{}{ + "input": marshalInput(input), + }, + } +} + +func (r *Runtime) Type(ctx context.Context, input LoadInput, policy TypePolicy) (TypeResult, error) { + op := TypeOperation(input, policy) + result, err := r.do(ctx, op.Document, op.Variables) + if err != nil { + return TypeResult{}, err + } + raw, err := extractPath(result.Data, "Environment", "load", "normalize", "validate", "assist", "typeSuggestions") + if err != nil { + return TypeResult{}, err + } + return decodeType(raw), nil +} + +func TypeOperation(input LoadInput, policy TypePolicy) Operation { + return Operation{ + Name: "OwlTypeSuggestions", + Document: typeQuery, + Variables: map[string]interface{}{ + "input": marshalInput(input), + "all": policy.All, + }, + } +} + func (r *Runtime) StateEnvelope(ctx context.Context, input LoadInput) (StateEnvelope, error) { - return r.StateEnvelopeForOperations(ctx, []store.OperationRecord{ - {Kind: store.OperationRecordLoad, Load: input}, + return r.StateEnvelopeForOperations(ctx, []state.OperationRecord{ + {Kind: state.OperationRecordLoad, Load: input}, }) } -func (r *Runtime) StateEnvelopeForOperations(ctx context.Context, records []store.OperationRecord) (StateEnvelope, error) { +func (r *Runtime) StateEnvelopeForOperations(ctx context.Context, records []state.OperationRecord) (StateEnvelope, error) { plan, err := planStateEnvelopeQuery(records) if err != nil { return StateEnvelope{}, err @@ -144,30 +264,37 @@ func (r *Runtime) StateEnvelopeForOperations(ctx context.Context, records []stor return decodeEnvelope(raw) } -func (r *Runtime) StateEnvelopeAfter(ctx context.Context, input LoadInput, patch store.LoadInput, deleted []string) (StateEnvelope, error) { - records := []store.OperationRecord{{Kind: store.OperationRecordLoad, Load: input}} +func StateEnvelopeOperation(records []state.OperationRecord) (Operation, error) { + plan, err := planStateEnvelopeQuery(records) + if err != nil { + return Operation{}, err + } + return Operation{Name: "OwlStateEnvelope", Document: plan.Query, Variables: plan.Vars}, nil +} + +func (r *Runtime) StateEnvelopeAfter(ctx context.Context, input LoadInput, patch state.LoadInput, deleted []string) (StateEnvelope, error) { + records := []state.OperationRecord{{Kind: state.OperationRecordLoad, Load: input}} if len(patch.Dotenv) > 0 { - records = append(records, store.OperationRecord{ - Kind: store.OperationRecordUpdate, - Update: store.UpdateOperation{ + records = append(records, state.OperationRecord{ + Kind: state.OperationRecordUpdate, + Update: state.UpdateOperation{ Source: patch.DotenvSource, Dotenv: patch.Dotenv, }, }) } if len(deleted) > 0 { - records = append(records, store.OperationRecord{ - Kind: store.OperationRecordDelete, - Delete: store.DeleteOperation{Keys: append([]string{}, deleted...)}, + records = append(records, state.OperationRecord{ + Kind: state.OperationRecordDelete, + Delete: state.DeleteOperation{Keys: append([]string{}, deleted...)}, }) } return r.StateEnvelopeForOperations(ctx, records) } func (r *Runtime) Check(ctx context.Context, input LoadInput) (CheckResult, error) { - result, err := r.do(ctx, checkQuery, map[string]interface{}{ - "input": marshalInput(input), - }) + op := CheckOperation(input) + result, err := r.do(ctx, op.Document, op.Variables) if err != nil { return CheckResult{}, err } @@ -178,6 +305,28 @@ func (r *Runtime) Check(ctx context.Context, input LoadInput) (CheckResult, erro return decodeCheck(raw), nil } +func CheckOperation(input LoadInput) Operation { + return Operation{ + Name: "OwlCheck", + Document: checkQuery, + Variables: map[string]interface{}{ + "input": marshalInput(input), + }, + } +} + +func (r *Runtime) Execute(ctx context.Context, query string, vars map[string]interface{}) (ExecuteResult, error) { + result, err := r.do(ctx, query, vars) + if err != nil { + return ExecuteResult{}, err + } + raw, err := json.Marshal(result.Data) + if err != nil { + return ExecuteResult{}, err + } + return ExecuteResult{Data: raw}, nil +} + func (r *Runtime) SchemaJSON(ctx context.Context) (string, error) { result, err := r.do(ctx, introspectionQuery, nil) if err != nil { @@ -306,7 +455,7 @@ func marshalEnvelope(envelope StateEnvelope) map[string]interface{} { } } -func marshalStateProvenance(provenance store.StateProvenance) map[string]interface{} { +func marshalStateProvenance(provenance state.StateProvenance) map[string]interface{} { sources := make([]map[string]interface{}, 0, len(provenance.Sources)) for _, source := range provenance.Sources { if marshaled := marshalSource(source); marshaled != nil { @@ -553,12 +702,9 @@ query OwlSnapshot($input: LoadInput!, $reveal: Boolean = false) { value originalValue type - field - fieldTypeID - fieldInstance - fieldName - source - origin + field { typeID instance field } + source { name kind } + origin { name kind } explicit confidence visibility @@ -590,20 +736,16 @@ func decodeSnapshot(raw interface{}) []SnapshotItem { Value: stringValue(item["value"]), OriginalValue: stringValue(item["originalValue"]), Type: model.TypeID(stringValue(item["type"])), - Field: model.FieldRef{ - TypeID: model.TypeID(stringValue(item["fieldTypeID"])), - Instance: stringValue(item["fieldInstance"]), - Field: stringValue(item["fieldName"]), - }, - Source: model.Source{Name: stringValue(item["source"])}, - Origin: model.Source{Name: stringValue(item["origin"])}, - Explicit: boolValue(item["explicit"]), - Confidence: model.BindingConfidence(stringValue(item["confidence"])), - Visibility: model.Visibility(stringValue(item["visibility"])), - Exposure: model.Exposure(stringValue(item["exposure"])), - Description: stringValue(item["description"]), - UpdatedAt: timeValue(item["updatedAt"]), - Diagnostics: decodeDiagnostics(item["diagnostics"]), + Field: decodeFieldRef(item["field"]), + Source: decodeSource(item["source"]), + Origin: decodeSource(item["origin"]), + Explicit: boolValue(item["explicit"]), + Confidence: model.BindingConfidence(stringValue(item["confidence"])), + Visibility: model.Visibility(stringValue(item["visibility"])), + Exposure: model.Exposure(stringValue(item["exposure"])), + Description: stringValue(item["description"]), + UpdatedAt: timeValue(item["updatedAt"]), + Diagnostics: decodeDiagnostics(item["diagnostics"]), }) } return items @@ -617,6 +759,7 @@ func decodeCheck(raw interface{}) CheckResult { return CheckResult{ OK: boolValue(row["ok"]), Diagnostics: decodeDiagnostics(row["diagnostics"]), + Checked: intValue(row["checked"]), } } @@ -636,6 +779,34 @@ func decodeGet(raw interface{}) GetResult { } } +func decodeType(raw interface{}) TypeResult { + row, ok := raw.(map[string]interface{}) + if !ok { + return TypeResult{} + } + proposalsRaw, ok := row["proposals"].([]interface{}) + if !ok { + return TypeResult{} + } + proposals := make([]state.TypeProposal, 0, len(proposalsRaw)) + for _, item := range proposalsRaw { + proposalRaw, ok := item.(map[string]interface{}) + if !ok { + continue + } + proposals = append(proposals, state.TypeProposal{ + Key: stringValue(proposalRaw["key"]), + CurrentType: model.TypeID(stringValue(proposalRaw["currentType"])), + SuggestedType: model.TypeID(stringValue(proposalRaw["suggestedType"])), + Confidence: model.BindingConfidence(stringValue(proposalRaw["confidence"])), + Reason: stringValue(proposalRaw["reason"]), + Description: stringValue(proposalRaw["description"]), + Required: boolValue(proposalRaw["required"]), + }) + } + return TypeResult{Proposals: proposals} +} + func decodeEnvelope(raw interface{}) (StateEnvelope, error) { row, ok := raw.(map[string]interface{}) if !ok { @@ -745,8 +916,8 @@ func decodeUnresolvedFrontier(raw interface{}) model.UnresolvedFrontier { return frontier } -func decodeStateProvenance(raw interface{}) store.StateProvenance { - var provenance store.StateProvenance +func decodeStateProvenance(raw interface{}) state.StateProvenance { + var provenance state.StateProvenance row, ok := raw.(map[string]interface{}) if !ok { return provenance @@ -855,7 +1026,62 @@ query OwlCheck($input: LoadInput!) { normalize { validate { render { - check { ok diagnostics { severity code message details key field owner } } + check { ok checked diagnostics { severity code message details key field owner } } + } + } + } + } + } +}` + +const dotenvSpecQuery = ` +query OwlDotenvSpec($input: LoadInput!) { + Environment { + load(input: $input) { + normalize { + validate { + render { + dotenvSpec + } + } + } + } + } +}` + +const projectSpecQuery = ` +query OwlProjectSpec($input: LoadInput!) { + Environment { + load(input: $input) { + normalize { + validate { + render { + dotenvSpec + } + } + } + } + } +}` + +const typeQuery = ` +query OwlTypeSuggestions($input: LoadInput!, $all: Boolean = false) { + Environment { + load(input: $input) { + normalize { + validate { + assist { + typeSuggestions(all: $all) { + proposals { + key + currentType + suggestedType + confidence + reason + description + required + } + } } } } diff --git a/internal/graph/runtime_test.go b/internal/graph/runtime_test.go index 96bcea7..3212302 100644 --- a/internal/graph/runtime_test.go +++ b/internal/graph/runtime_test.go @@ -2,6 +2,8 @@ package graph import ( "context" + "encoding/json" + "sort" "testing" "time" @@ -10,7 +12,7 @@ import ( "github.com/runmedev/owl/internal/model" "github.com/runmedev/owl/internal/resolver" - "github.com/runmedev/owl/internal/store" + "github.com/runmedev/owl/internal/state" ) func TestRuntimeDrivesLoadNormalizeValidateSnapshot(t *testing.T) { @@ -19,18 +21,18 @@ func TestRuntimeDrivesLoadNormalizeValidateSnapshot(t *testing.T) { runtime, err := NewRuntime(nil) require.NoError(t, err) - items, err := runtime.Snapshot(context.Background(), store.LoadInput{ + items, err := runtime.Snapshot(context.Background(), state.LoadInput{ DotenvSource: model.Source{Name: ".env", Kind: "dotenv"}, - Dotenv: []store.DotenvVariable{ + Dotenv: []state.DotenvVariable{ {Key: "API_URL", Value: "https://api.example.com"}, {Key: "API_KEY", Value: "secret"}, {Key: "REDIS_HOST", Value: "localhost"}, }, - Contracts: []store.EnvContract{ + Contracts: []state.EnvContract{ { Source: model.Source{Name: ".env.spec", Kind: "dotenv-spec"}, Projection: model.ProjectionDotenv, - Bindings: []store.EnvBinding{ + Bindings: []state.EnvBinding{ { Key: "API_URL", FieldRef: model.FieldRef{TypeID: model.TypeCorePlain, Instance: "default", Field: "api.url"}, @@ -66,17 +68,17 @@ func TestRuntimeRendersDotenvThroughGraphQL(t *testing.T) { runtime, err := NewRuntime(nil) require.NoError(t, err) - envs, err := runtime.Dotenv(context.Background(), store.LoadInput{ + envs, err := runtime.Dotenv(context.Background(), state.LoadInput{ DotenvSource: model.Source{Name: ".env", Kind: "dotenv"}, - Dotenv: []store.DotenvVariable{ + Dotenv: []state.DotenvVariable{ {Key: "API_KEY", Value: "secret"}, {Key: "API_URL", Value: "https://api.example.com"}, }, - Contracts: []store.EnvContract{ + Contracts: []state.EnvContract{ { Source: model.Source{Name: "package.json", Kind: "package-json"}, Projection: model.ProjectionDotenv, - Bindings: []store.EnvBinding{ + Bindings: []state.EnvBinding{ { Key: "API_KEY", FieldRef: model.FieldRef{TypeID: model.TypeCoreSecret, Instance: "default", Field: "api.key"}, @@ -105,13 +107,13 @@ func TestRuntimeCheckReportsRequiredDiagnostics(t *testing.T) { runtime, err := NewRuntime(nil) require.NoError(t, err) - check, err := runtime.Check(context.Background(), store.LoadInput{ + check, err := runtime.Check(context.Background(), state.LoadInput{ DotenvSource: model.Source{Name: ".env", Kind: "dotenv"}, - Contracts: []store.EnvContract{ + Contracts: []state.EnvContract{ { Source: model.Source{Name: ".env.spec", Kind: "dotenv-spec"}, Projection: model.ProjectionDotenv, - Bindings: []store.EnvBinding{ + Bindings: []state.EnvBinding{ { Key: "API_KEY", FieldRef: model.FieldRef{TypeID: model.TypeCoreSecret, Instance: "default", Field: "api.key"}, @@ -165,30 +167,93 @@ func TestRuntimeSchemaUsesVisibilityAndExposureNames(t *testing.T) { } } +func TestRuntimeSchemaInputFieldsMatchBoundaryDescriptors(t *testing.T) { + t.Parallel() + + runtime, err := NewRuntime(nil) + require.NoError(t, err) + + schemaJSON, err := runtime.SchemaJSON(context.Background()) + require.NoError(t, err) + + fieldsByInput := introspectionInputFields(t, schemaJSON) + for inputName, want := range map[string][]string{ + "SourceInput": {"kind", "name"}, + "FieldRefInput": {"field", "instance", "typeID"}, + "DotenvVariableInput": {"key", "source", "value"}, + "DotenvInput": {"source", "timestamp", "variables"}, + "EnvBindingInput": {"description", "exposure", "field", "key", "order", "projection", "required", "sensitivity", "source"}, + "EnvContractInput": {"bindings", "projection", "source"}, + "DiagnosticInput": {"code", "details", "field", "key", "message", "owner", "severity"}, + "ResolverAttemptInput": {"diagnostics", "field", "finishedAt", "id", "message", "outcome", "projectionKey", "resolverID", "source", "startedAt"}, + "ResolverProposalInput": {"attemptID", "field", "needID", "projectionKey", "resolverID", "value"}, + "StateEnvelopeInput": {"modelVersion", "provenance", "state"}, + "LoadInput": {"contracts", "dotenv", "envelope", "timestamp"}, + "OperationMetadataInput": {"actor", "id", "kind", "projection", "source", "timestamp"}, + "StateProvenanceInput": {"operations", "sources"}, + "EffectiveStateInput": {"bindings", "diagnostics", "resolverAttempts", "unresolvedFrontier", "values"}, + "UnresolvedFrontierInput": {"needs"}, + } { + got, ok := fieldsByInput[inputName] + require.True(t, ok, "missing input %s", inputName) + assert.Equal(t, want, got, inputName) + } +} + +func introspectionInputFields(t *testing.T, schemaJSON string) map[string][]string { + t.Helper() + + var payload struct { + Schema struct { + Types []struct { + Name string `json:"name"` + InputFields []struct { + Name string `json:"name"` + } `json:"inputFields"` + } `json:"types"` + } `json:"__schema"` + } + require.NoError(t, json.Unmarshal([]byte(schemaJSON), &payload)) + + fieldsByInput := make(map[string][]string) + for _, typ := range payload.Schema.Types { + if len(typ.InputFields) == 0 { + continue + } + fields := make([]string, 0, len(typ.InputFields)) + for _, field := range typ.InputFields { + fields = append(fields, field.Name) + } + sort.Strings(fields) + fieldsByInput[typ.Name] = fields + } + return fieldsByInput +} + func TestPlanStateEnvelopeQueryStacksOperationRecords(t *testing.T) { t.Parallel() - plan, err := planStateEnvelopeQuery([]store.OperationRecord{ + plan, err := planStateEnvelopeQuery([]state.OperationRecord{ { - Kind: store.OperationRecordLoad, - Load: store.LoadInput{ + Kind: state.OperationRecordLoad, + Load: state.LoadInput{ DotenvSource: model.Source{Name: ".env", Kind: "dotenv"}, - Dotenv: []store.DotenvVariable{{Key: "API_URL", Value: "https://api.example.com"}}, + Dotenv: []state.DotenvVariable{{Key: "API_URL", Value: "https://api.example.com"}}, }, }, { - Kind: store.OperationRecordUpdate, - Update: store.UpdateOperation{ + Kind: state.OperationRecordUpdate, + Update: state.UpdateOperation{ Source: model.Source{Name: "[update]", Kind: "dotenv"}, - Dotenv: []store.DotenvVariable{{Key: "API_URL", Value: "https://next.example.com"}}, + Dotenv: []state.DotenvVariable{{Key: "API_URL", Value: "https://next.example.com"}}, }, }, { - Kind: store.OperationRecordDelete, - Delete: store.DeleteOperation{Keys: []string{"API_KEY"}}, + Kind: state.OperationRecordDelete, + Delete: state.DeleteOperation{Keys: []string{"API_KEY"}}, }, { - Kind: store.OperationRecordResolverAttempt, + Kind: state.OperationRecordResolverAttempt, ResolverAttempt: model.ResolverAttempt{ ID: "attempt-000001", ResolverID: "core/dotenv", @@ -198,7 +263,7 @@ func TestPlanStateEnvelopeQueryStacksOperationRecords(t *testing.T) { }, }, { - Kind: store.OperationRecordApplyResolverProposal, + Kind: state.OperationRecordApplyResolverProposal, ResolverProposal: resolver.Proposal{ AttemptID: "attempt-000002", ResolverID: "core/dotenv", @@ -236,20 +301,20 @@ func TestRuntimeMaterializesStateEnvelopeFromOperationRecords(t *testing.T) { runtime, err := NewRuntime(nil) require.NoError(t, err) - envelope, err := runtime.StateEnvelopeForOperations(context.Background(), []store.OperationRecord{ + envelope, err := runtime.StateEnvelopeForOperations(context.Background(), []state.OperationRecord{ { - Kind: store.OperationRecordLoad, - Load: store.LoadInput{ + Kind: state.OperationRecordLoad, + Load: state.LoadInput{ DotenvSource: model.Source{Name: ".env", Kind: "dotenv"}, - Dotenv: []store.DotenvVariable{ + Dotenv: []state.DotenvVariable{ {Key: "API_URL", Value: "https://api.example.com"}, {Key: "API_KEY", Value: "secret"}, }, - Contracts: []store.EnvContract{ + Contracts: []state.EnvContract{ { Source: model.Source{Name: ".env.spec", Kind: "dotenv-spec"}, Projection: model.ProjectionDotenv, - Bindings: []store.EnvBinding{ + Bindings: []state.EnvBinding{ { Key: "API_KEY", FieldRef: model.FieldRef{TypeID: model.TypeCoreSecret, Instance: "default", Field: "api.key"}, @@ -262,26 +327,26 @@ func TestRuntimeMaterializesStateEnvelopeFromOperationRecords(t *testing.T) { }, }, { - Kind: store.OperationRecordUpdate, - Update: store.UpdateOperation{ + Kind: state.OperationRecordUpdate, + Update: state.UpdateOperation{ Source: model.Source{Name: "[update]", Kind: "dotenv"}, - Dotenv: []store.DotenvVariable{{Key: "API_URL", Value: "https://next.example.com"}}, + Dotenv: []state.DotenvVariable{{Key: "API_URL", Value: "https://next.example.com"}}, }, }, { - Kind: store.OperationRecordDelete, - Delete: store.DeleteOperation{Keys: []string{"API_KEY"}}, + Kind: state.OperationRecordDelete, + Delete: state.DeleteOperation{Keys: []string{"API_KEY"}}, }, }) require.NoError(t, err) - s := store.NewState(envelope.State, nil) - got, ok, err := s.Get("API_URL", store.GetPolicy{Reveal: true}) + s := state.MachineFromState(envelope.State, nil) + got, ok, err := s.Get("API_URL", state.GetPolicy{Reveal: true}) require.NoError(t, err) require.True(t, ok) assert.Equal(t, "https://next.example.com", got.Value) assert.False(t, envelope.State.Values[got.Field].UpdatedAt.IsZero()) - _, ok, err = s.Get("API_KEY", store.GetPolicy{Reveal: true}) + _, ok, err = s.Get("API_KEY", state.GetPolicy{Reveal: true}) require.NoError(t, err) assert.False(t, ok) } @@ -315,16 +380,16 @@ func TestRuntimeMaterializesResolverAttemptsFromOperationRecords(t *testing.T) { }, } - envelope, err := runtime.StateEnvelopeForOperations(context.Background(), []store.OperationRecord{ + envelope, err := runtime.StateEnvelopeForOperations(context.Background(), []state.OperationRecord{ { - Kind: store.OperationRecordLoad, - Load: store.LoadInput{ + Kind: state.OperationRecordLoad, + Load: state.LoadInput{ DotenvSource: model.Source{Name: ".env", Kind: "dotenv"}, - Dotenv: []store.DotenvVariable{{Key: "API_URL", Value: "https://api.example.com"}}, + Dotenv: []state.DotenvVariable{{Key: "API_URL", Value: "https://api.example.com"}}, }, }, { - Kind: store.OperationRecordResolverAttempt, + Kind: state.OperationRecordResolverAttempt, ResolverAttempt: attempt, }, }) @@ -334,8 +399,8 @@ func TestRuntimeMaterializesResolverAttemptsFromOperationRecords(t *testing.T) { assert.Equal(t, attempt, envelope.State.ResolverAttempts[0]) assert.NotContains(t, envelope.State.ResolverAttempts[0].Message, "secret") - s := store.NewState(envelope.State, nil) - got, ok, err := s.Get("API_URL", store.GetPolicy{Reveal: true}) + s := state.MachineFromState(envelope.State, nil) + got, ok, err := s.Get("API_URL", state.GetPolicy{Reveal: true}) require.NoError(t, err) require.True(t, ok) assert.Equal(t, "https://api.example.com", got.Value) @@ -349,15 +414,15 @@ func TestRuntimeMaterializesResolverProposalsFromOperationRecords(t *testing.T) ref := model.FieldRef{TypeID: model.TypeCoreSecret, Instance: "default", Field: "api.key"} timestamp := time.Date(2026, 8, 3, 20, 30, 0, 0, time.UTC) - envelope, err := runtime.StateEnvelopeForOperations(context.Background(), []store.OperationRecord{ + envelope, err := runtime.StateEnvelopeForOperations(context.Background(), []state.OperationRecord{ { - Kind: store.OperationRecordLoad, - Load: store.LoadInput{ + Kind: state.OperationRecordLoad, + Load: state.LoadInput{ DotenvSource: model.Source{Name: ".env", Kind: "dotenv"}, - Contracts: []store.EnvContract{{ + Contracts: []state.EnvContract{{ Source: model.Source{Name: ".env.example", Kind: "dotenv-spec"}, Projection: model.ProjectionDotenv, - Bindings: []store.EnvBinding{{ + Bindings: []state.EnvBinding{{ Key: "API_KEY", FieldRef: ref, Projection: model.ProjectionDotenv, @@ -367,7 +432,7 @@ func TestRuntimeMaterializesResolverProposalsFromOperationRecords(t *testing.T) }, }, { - Kind: store.OperationRecordApplyResolverProposal, + Kind: state.OperationRecordApplyResolverProposal, Timestamp: timestamp, ResolverProposal: resolver.Proposal{ AttemptID: "attempt-000001", @@ -385,8 +450,8 @@ func TestRuntimeMaterializesResolverProposalsFromOperationRecords(t *testing.T) }) require.NoError(t, err) - s := store.NewState(envelope.State, nil) - got, ok, err := s.Get("API_KEY", store.GetPolicy{Reveal: true}) + s := state.MachineFromState(envelope.State, nil) + got, ok, err := s.Get("API_KEY", state.GetPolicy{Reveal: true}) require.NoError(t, err) require.True(t, ok) assert.Equal(t, "secret", got.Value) @@ -403,14 +468,14 @@ func TestRuntimeMaterializesUnresolvedFrontier(t *testing.T) { runtime, err := NewRuntime(nil) require.NoError(t, err) - envelope, err := runtime.StateEnvelope(context.Background(), store.LoadInput{ + envelope, err := runtime.StateEnvelope(context.Background(), state.LoadInput{ DotenvSource: model.Source{Name: ".env", Kind: "dotenv"}, - Dotenv: []store.DotenvVariable{{Key: "PRESENT_SECRET", Value: "secret"}}, - Contracts: []store.EnvContract{ + Dotenv: []state.DotenvVariable{{Key: "PRESENT_SECRET", Value: "secret"}}, + Contracts: []state.EnvContract{ { Source: model.Source{Name: "owl.toml", Kind: "owl-config"}, Projection: model.ProjectionDotenv, - Bindings: []store.EnvBinding{ + Bindings: []state.EnvBinding{ { Key: "MISSING_SECRET", FieldRef: model.FieldRef{TypeID: model.TypeCoreSecret, Instance: "default", Field: "missing.secret"}, diff --git a/internal/graph/schema.go b/internal/graph/schema.go index 4635358..d74bbd0 100644 --- a/internal/graph/schema.go +++ b/internal/graph/schema.go @@ -9,211 +9,155 @@ import ( "github.com/graphql-go/graphql" "github.com/runmedev/owl/internal/model" + "github.com/runmedev/owl/internal/requirements" "github.com/runmedev/owl/internal/resolver" - "github.com/runmedev/owl/internal/store" + "github.com/runmedev/owl/internal/state" ) func (r *Runtime) newSchema() (graphql.Schema, error) { - sourceInput := graphql.NewInputObject(graphql.InputObjectConfig{ - Name: "SourceInput", - Fields: graphql.InputObjectConfigFieldMap{ - "name": &graphql.InputObjectFieldConfig{Type: graphql.NewNonNull(graphql.String)}, - "kind": &graphql.InputObjectFieldConfig{Type: graphql.NewNonNull(graphql.String)}, - }, - }) - fieldRefInput := graphql.NewInputObject(graphql.InputObjectConfig{ - Name: "FieldRefInput", - Fields: graphql.InputObjectConfigFieldMap{ - "typeID": &graphql.InputObjectFieldConfig{Type: graphql.NewNonNull(graphql.String)}, - "instance": &graphql.InputObjectFieldConfig{Type: graphql.String}, - "field": &graphql.InputObjectFieldConfig{Type: graphql.NewNonNull(graphql.String)}, - }, - }) - dotenvVariableInput := graphql.NewInputObject(graphql.InputObjectConfig{ - Name: "DotenvVariableInput", - Fields: graphql.InputObjectConfigFieldMap{ - "key": &graphql.InputObjectFieldConfig{Type: graphql.NewNonNull(graphql.String)}, - "value": &graphql.InputObjectFieldConfig{Type: graphql.String}, - "source": &graphql.InputObjectFieldConfig{Type: sourceInput}, - }, - }) - dotenvInput := graphql.NewInputObject(graphql.InputObjectConfig{ - Name: "DotenvInput", - Fields: graphql.InputObjectConfigFieldMap{ - "source": &graphql.InputObjectFieldConfig{Type: sourceInput}, - "variables": &graphql.InputObjectFieldConfig{Type: graphql.NewList(graphql.NewNonNull(dotenvVariableInput))}, - "timestamp": &graphql.InputObjectFieldConfig{Type: graphql.String}, - }, - }) - envBindingInput := graphql.NewInputObject(graphql.InputObjectConfig{ - Name: "EnvBindingInput", - Fields: graphql.InputObjectConfigFieldMap{ - "field": &graphql.InputObjectFieldConfig{Type: graphql.NewNonNull(fieldRefInput)}, - "key": &graphql.InputObjectFieldConfig{Type: graphql.String}, - "projection": &graphql.InputObjectFieldConfig{Type: graphql.String}, - "required": &graphql.InputObjectFieldConfig{Type: graphql.Boolean}, - "description": &graphql.InputObjectFieldConfig{Type: graphql.String}, - "source": &graphql.InputObjectFieldConfig{Type: sourceInput}, - "order": &graphql.InputObjectFieldConfig{Type: graphql.Int}, - "sensitivity": &graphql.InputObjectFieldConfig{Type: graphql.String}, - "exposure": &graphql.InputObjectFieldConfig{Type: graphql.String}, - }, - }) - envContractInput := graphql.NewInputObject(graphql.InputObjectConfig{ - Name: "EnvContractInput", - Fields: graphql.InputObjectConfigFieldMap{ - "source": &graphql.InputObjectFieldConfig{Type: sourceInput}, - "projection": &graphql.InputObjectFieldConfig{Type: graphql.String}, - "bindings": &graphql.InputObjectFieldConfig{Type: graphql.NewList(graphql.NewNonNull(envBindingInput))}, - }, - }) - diagnosticInput := graphql.NewInputObject(graphql.InputObjectConfig{ - Name: "DiagnosticInput", - Fields: graphql.InputObjectConfigFieldMap{ - "severity": &graphql.InputObjectFieldConfig{Type: graphql.String}, - "code": &graphql.InputObjectFieldConfig{Type: graphql.String}, - "message": &graphql.InputObjectFieldConfig{Type: graphql.String}, - "details": &graphql.InputObjectFieldConfig{Type: graphql.NewList(graphql.String)}, - "key": &graphql.InputObjectFieldConfig{Type: graphql.String}, - "field": &graphql.InputObjectFieldConfig{Type: fieldRefInput}, - "owner": &graphql.InputObjectFieldConfig{Type: graphql.String}, - }, - }) - resolverAttemptInput := graphql.NewInputObject(graphql.InputObjectConfig{ - Name: "ResolverAttemptInput", - Fields: graphql.InputObjectConfigFieldMap{ - "id": &graphql.InputObjectFieldConfig{Type: graphql.String}, - "resolverID": &graphql.InputObjectFieldConfig{Type: graphql.NewNonNull(graphql.String)}, - "field": &graphql.InputObjectFieldConfig{Type: fieldRefInput}, - "projectionKey": &graphql.InputObjectFieldConfig{Type: graphql.String}, - "outcome": &graphql.InputObjectFieldConfig{Type: graphql.NewNonNull(graphql.String)}, - "message": &graphql.InputObjectFieldConfig{Type: graphql.String}, - "source": &graphql.InputObjectFieldConfig{Type: sourceInput}, - "startedAt": &graphql.InputObjectFieldConfig{Type: graphql.String}, - "finishedAt": &graphql.InputObjectFieldConfig{Type: graphql.String}, - "diagnostics": &graphql.InputObjectFieldConfig{Type: graphql.NewList(graphql.NewNonNull(diagnosticInput))}, - }, - }) - proposedValueInput := graphql.NewInputObject(graphql.InputObjectConfig{ - Name: "ProposedValueInput", - Fields: graphql.InputObjectConfigFieldMap{ - "value": &graphql.InputObjectFieldConfig{Type: graphql.NewNonNull(graphql.String)}, - "source": &graphql.InputObjectFieldConfig{Type: sourceInput}, - "sensitivity": &graphql.InputObjectFieldConfig{Type: graphql.String}, - "exposure": &graphql.InputObjectFieldConfig{Type: graphql.String}, - }, - }) - resolverProposalInput := graphql.NewInputObject(graphql.InputObjectConfig{ - Name: "ResolverProposalInput", - Fields: graphql.InputObjectConfigFieldMap{ - "needID": &graphql.InputObjectFieldConfig{Type: graphql.String}, - "attemptID": &graphql.InputObjectFieldConfig{Type: graphql.String}, - "resolverID": &graphql.InputObjectFieldConfig{Type: graphql.NewNonNull(graphql.String)}, - "field": &graphql.InputObjectFieldConfig{Type: graphql.NewNonNull(fieldRefInput)}, - "projectionKey": &graphql.InputObjectFieldConfig{Type: graphql.NewNonNull(graphql.String)}, - "value": &graphql.InputObjectFieldConfig{Type: graphql.NewNonNull(proposedValueInput)}, - }, - }) - unresolvedNeedInput := graphql.NewInputObject(graphql.InputObjectConfig{ - Name: "UnresolvedNeedInput", - Fields: graphql.InputObjectConfigFieldMap{ - "id": &graphql.InputObjectFieldConfig{Type: graphql.String}, - "field": &graphql.InputObjectFieldConfig{Type: graphql.NewNonNull(fieldRefInput)}, - "projectionKey": &graphql.InputObjectFieldConfig{Type: graphql.String}, - "required": &graphql.InputObjectFieldConfig{Type: graphql.Boolean}, - "blocking": &graphql.InputObjectFieldConfig{Type: graphql.Boolean}, - "reason": &graphql.InputObjectFieldConfig{Type: graphql.String}, - "description": &graphql.InputObjectFieldConfig{Type: graphql.String}, - "sensitivity": &graphql.InputObjectFieldConfig{Type: graphql.String}, - "exposure": &graphql.InputObjectFieldConfig{Type: graphql.String}, - "source": &graphql.InputObjectFieldConfig{Type: sourceInput}, - "origin": &graphql.InputObjectFieldConfig{Type: sourceInput}, - "resolverAttemptIDs": &graphql.InputObjectFieldConfig{Type: graphql.NewList(graphql.String)}, - }, - }) - unresolvedFrontierInput := graphql.NewInputObject(graphql.InputObjectConfig{ - Name: "UnresolvedFrontierInput", - Fields: graphql.InputObjectConfigFieldMap{ - "needs": &graphql.InputObjectFieldConfig{Type: graphql.NewList(graphql.NewNonNull(unresolvedNeedInput))}, - }, - }) - stateValueInput := graphql.NewInputObject(graphql.InputObjectConfig{ - Name: "StateValueInput", - Fields: graphql.InputObjectConfigFieldMap{ - "field": &graphql.InputObjectFieldConfig{Type: graphql.NewNonNull(fieldRefInput)}, - "original": &graphql.InputObjectFieldConfig{Type: graphql.String}, - "resolved": &graphql.InputObjectFieldConfig{Type: graphql.String}, - "visibility": &graphql.InputObjectFieldConfig{Type: graphql.String}, - "sensitivity": &graphql.InputObjectFieldConfig{Type: graphql.String}, - "exposure": &graphql.InputObjectFieldConfig{Type: graphql.String}, - "origin": &graphql.InputObjectFieldConfig{Type: sourceInput}, - "source": &graphql.InputObjectFieldConfig{Type: sourceInput}, - "createdAt": &graphql.InputObjectFieldConfig{Type: graphql.String}, - "updatedAt": &graphql.InputObjectFieldConfig{Type: graphql.String}, - }, - }) - stateBindingInput := graphql.NewInputObject(graphql.InputObjectConfig{ - Name: "StateBindingInput", - Fields: graphql.InputObjectConfigFieldMap{ - "id": &graphql.InputObjectFieldConfig{Type: graphql.String}, - "field": &graphql.InputObjectFieldConfig{Type: graphql.NewNonNull(fieldRefInput)}, - "projection": &graphql.InputObjectFieldConfig{Type: graphql.String}, - "key": &graphql.InputObjectFieldConfig{Type: graphql.String}, - "description": &graphql.InputObjectFieldConfig{Type: graphql.String}, - "source": &graphql.InputObjectFieldConfig{Type: sourceInput}, - "origin": &graphql.InputObjectFieldConfig{Type: sourceInput}, - "confidence": &graphql.InputObjectFieldConfig{Type: graphql.String}, - "explicit": &graphql.InputObjectFieldConfig{Type: graphql.Boolean}, - "order": &graphql.InputObjectFieldConfig{Type: graphql.Int}, - "preserveKey": &graphql.InputObjectFieldConfig{Type: graphql.Boolean}, - "required": &graphql.InputObjectFieldConfig{Type: graphql.Boolean}, - }, - }) - effectiveStateInput := graphql.NewInputObject(graphql.InputObjectConfig{ - Name: "EffectiveStateInput", - Fields: graphql.InputObjectConfigFieldMap{ - "values": &graphql.InputObjectFieldConfig{Type: graphql.NewList(graphql.NewNonNull(stateValueInput))}, - "bindings": &graphql.InputObjectFieldConfig{Type: graphql.NewList(graphql.NewNonNull(stateBindingInput))}, - "resolverAttempts": &graphql.InputObjectFieldConfig{Type: graphql.NewList(graphql.NewNonNull(resolverAttemptInput))}, - "unresolvedFrontier": &graphql.InputObjectFieldConfig{Type: unresolvedFrontierInput}, - "diagnostics": &graphql.InputObjectFieldConfig{Type: graphql.NewList(graphql.NewNonNull(diagnosticInput))}, - }, - }) - operationMetadataInput := graphql.NewInputObject(graphql.InputObjectConfig{ - Name: "OperationMetadataInput", - Fields: graphql.InputObjectConfigFieldMap{ - "id": &graphql.InputObjectFieldConfig{Type: graphql.String}, - "kind": &graphql.InputObjectFieldConfig{Type: graphql.String}, - "timestamp": &graphql.InputObjectFieldConfig{Type: graphql.String}, - "actor": &graphql.InputObjectFieldConfig{Type: graphql.String}, - "source": &graphql.InputObjectFieldConfig{Type: sourceInput}, - "projection": &graphql.InputObjectFieldConfig{Type: graphql.String}, - }, - }) - stateProvenanceInput := graphql.NewInputObject(graphql.InputObjectConfig{ - Name: "StateProvenanceInput", - Fields: graphql.InputObjectConfigFieldMap{ - "sources": &graphql.InputObjectFieldConfig{Type: graphql.NewList(graphql.NewNonNull(sourceInput))}, - "operations": &graphql.InputObjectFieldConfig{Type: graphql.NewList(graphql.NewNonNull(operationMetadataInput))}, - }, - }) - stateEnvelopeInput := graphql.NewInputObject(graphql.InputObjectConfig{ - Name: "StateEnvelopeInput", - Fields: graphql.InputObjectConfigFieldMap{ - "modelVersion": &graphql.InputObjectFieldConfig{Type: graphql.NewNonNull(graphql.String)}, - "state": &graphql.InputObjectFieldConfig{Type: graphql.NewNonNull(effectiveStateInput)}, - "provenance": &graphql.InputObjectFieldConfig{Type: stateProvenanceInput}, - }, - }) - loadInput := graphql.NewInputObject(graphql.InputObjectConfig{ - Name: "LoadInput", - Fields: graphql.InputObjectConfigFieldMap{ - "dotenv": &graphql.InputObjectFieldConfig{Type: dotenvInput}, - "contracts": &graphql.InputObjectFieldConfig{Type: graphql.NewList(graphql.NewNonNull(envContractInput))}, - "envelope": &graphql.InputObjectFieldConfig{Type: stateEnvelopeInput}, - "timestamp": &graphql.InputObjectFieldConfig{Type: graphql.String}, - }, - }) + sourceInput := graphInputObject("SourceInput", + graphInput("name", graphql.NewNonNull(graphql.String)), + graphInput("kind", graphql.NewNonNull(graphql.String)), + ) + fieldRefInput := graphInputObject("FieldRefInput", + graphInput("typeID", graphql.NewNonNull(graphql.String)), + graphInput("instance", graphql.String), + graphInput("field", graphql.NewNonNull(graphql.String)), + ) + dotenvVariableInput := graphInputObject("DotenvVariableInput", + graphInput("key", graphql.NewNonNull(graphql.String)), + graphInput("value", graphql.String), + graphInput("source", sourceInput), + ) + dotenvInput := graphInputObject("DotenvInput", + graphInput("source", sourceInput), + graphInput("variables", graphql.NewList(graphql.NewNonNull(dotenvVariableInput))), + graphInput("timestamp", graphql.String), + ) + envBindingInput := graphInputObject("EnvBindingInput", + graphInput("field", graphql.NewNonNull(fieldRefInput)), + graphInput("key", graphql.String), + graphInput("projection", graphql.String), + graphInput("required", graphql.Boolean), + graphInput("description", graphql.String), + graphInput("source", sourceInput), + graphInput("order", graphql.Int), + graphInput("sensitivity", graphql.String), + graphInput("exposure", graphql.String), + ) + envContractInput := graphInputObject("EnvContractInput", + graphInput("source", sourceInput), + graphInput("projection", graphql.String), + graphInput("bindings", graphql.NewList(graphql.NewNonNull(envBindingInput))), + ) + diagnosticInput := graphInputObject("DiagnosticInput", + graphInput("severity", graphql.String), + graphInput("code", graphql.String), + graphInput("message", graphql.String), + graphInput("details", graphql.NewList(graphql.String)), + graphInput("key", graphql.String), + graphInput("field", fieldRefInput), + graphInput("owner", graphql.String), + ) + resolverAttemptInput := graphInputObject("ResolverAttemptInput", + graphInput("id", graphql.String), + graphInput("resolverID", graphql.NewNonNull(graphql.String)), + graphInput("field", fieldRefInput), + graphInput("projectionKey", graphql.String), + graphInput("outcome", graphql.NewNonNull(graphql.String)), + graphInput("message", graphql.String), + graphInput("source", sourceInput), + graphInput("startedAt", graphql.String), + graphInput("finishedAt", graphql.String), + graphInput("diagnostics", graphql.NewList(graphql.NewNonNull(diagnosticInput))), + ) + proposedValueInput := graphInputObject("ProposedValueInput", + graphInput("value", graphql.NewNonNull(graphql.String)), + graphInput("source", sourceInput), + graphInput("sensitivity", graphql.String), + graphInput("exposure", graphql.String), + ) + resolverProposalInput := graphInputObject("ResolverProposalInput", + graphInput("needID", graphql.String), + graphInput("attemptID", graphql.String), + graphInput("resolverID", graphql.NewNonNull(graphql.String)), + graphInput("field", graphql.NewNonNull(fieldRefInput)), + graphInput("projectionKey", graphql.NewNonNull(graphql.String)), + graphInput("value", graphql.NewNonNull(proposedValueInput)), + ) + unresolvedNeedInput := graphInputObject("UnresolvedNeedInput", + graphInput("id", graphql.String), + graphInput("field", graphql.NewNonNull(fieldRefInput)), + graphInput("projectionKey", graphql.String), + graphInput("required", graphql.Boolean), + graphInput("blocking", graphql.Boolean), + graphInput("reason", graphql.String), + graphInput("description", graphql.String), + graphInput("sensitivity", graphql.String), + graphInput("exposure", graphql.String), + graphInput("source", sourceInput), + graphInput("origin", sourceInput), + graphInput("resolverAttemptIDs", graphql.NewList(graphql.String)), + ) + unresolvedFrontierInput := graphInputObject("UnresolvedFrontierInput", + graphInput("needs", graphql.NewList(graphql.NewNonNull(unresolvedNeedInput))), + ) + stateValueInput := graphInputObject("StateValueInput", + graphInput("field", graphql.NewNonNull(fieldRefInput)), + graphInput("original", graphql.String), + graphInput("resolved", graphql.String), + graphInput("visibility", graphql.String), + graphInput("sensitivity", graphql.String), + graphInput("exposure", graphql.String), + graphInput("origin", sourceInput), + graphInput("source", sourceInput), + graphInput("createdAt", graphql.String), + graphInput("updatedAt", graphql.String), + ) + stateBindingInput := graphInputObject("StateBindingInput", + graphInput("id", graphql.String), + graphInput("field", graphql.NewNonNull(fieldRefInput)), + graphInput("projection", graphql.String), + graphInput("key", graphql.String), + graphInput("description", graphql.String), + graphInput("source", sourceInput), + graphInput("origin", sourceInput), + graphInput("confidence", graphql.String), + graphInput("explicit", graphql.Boolean), + graphInput("order", graphql.Int), + graphInput("preserveKey", graphql.Boolean), + graphInput("required", graphql.Boolean), + ) + effectiveStateInput := graphInputObject("EffectiveStateInput", + graphInput("values", graphql.NewList(graphql.NewNonNull(stateValueInput))), + graphInput("bindings", graphql.NewList(graphql.NewNonNull(stateBindingInput))), + graphInput("resolverAttempts", graphql.NewList(graphql.NewNonNull(resolverAttemptInput))), + graphInput("unresolvedFrontier", unresolvedFrontierInput), + graphInput("diagnostics", graphql.NewList(graphql.NewNonNull(diagnosticInput))), + ) + operationMetadataInput := graphInputObject("OperationMetadataInput", + graphInput("id", graphql.String), + graphInput("kind", graphql.String), + graphInput("timestamp", graphql.String), + graphInput("actor", graphql.String), + graphInput("source", sourceInput), + graphInput("projection", graphql.String), + ) + stateProvenanceInput := graphInputObject("StateProvenanceInput", + graphInput("sources", graphql.NewList(graphql.NewNonNull(sourceInput))), + graphInput("operations", graphql.NewList(graphql.NewNonNull(operationMetadataInput))), + ) + stateEnvelopeInput := graphInputObject("StateEnvelopeInput", + graphInput("modelVersion", graphql.NewNonNull(graphql.String)), + graphInput("state", graphql.NewNonNull(effectiveStateInput)), + graphInput("provenance", stateProvenanceInput), + ) + loadInput := graphInputObject("LoadInput", + graphInput("dotenv", dotenvInput), + graphInput("contracts", graphql.NewList(graphql.NewNonNull(envContractInput))), + graphInput("envelope", stateEnvelopeInput), + graphInput("timestamp", graphql.String), + ) diagnosticType := graphql.NewObject(graphql.ObjectConfig{ Name: "Diagnostic", @@ -393,12 +337,30 @@ func (r *Runtime) newSchema() (graphql.Schema, error) { "envelope": &graphql.Field{ Type: stateEnvelopeType, Resolve: func(p graphql.ResolveParams) (interface{}, error) { - gctx := p.Source.(Context) - return stateEnvelopeView(store.NewState(gctx.State, gctx.Types).StateEnvelope()), nil + gctx := p.Source.(GraphContext) + return stateEnvelopeView(state.MachineFromState(gctx.State, gctx.Types).StateEnvelope()), nil }, }, }, }) + typeProposalType := graphql.NewObject(graphql.ObjectConfig{ + Name: "TypeProposal", + Fields: graphql.Fields{ + "key": &graphql.Field{Type: graphql.String}, + "currentType": &graphql.Field{Type: graphql.String}, + "suggestedType": &graphql.Field{Type: graphql.String}, + "confidence": &graphql.Field{Type: graphql.String}, + "reason": &graphql.Field{Type: graphql.String}, + "description": &graphql.Field{Type: graphql.String}, + "required": &graphql.Field{Type: graphql.Boolean}, + }, + }) + typeResultType := graphql.NewObject(graphql.ObjectConfig{ + Name: "TypeResult", + Fields: graphql.Fields{ + "proposals": &graphql.Field{Type: graphql.NewList(typeProposalType)}, + }, + }) getResultType := graphql.NewObject(graphql.ObjectConfig{ Name: "GetResult", Fields: graphql.Fields{ @@ -416,6 +378,7 @@ func (r *Runtime) newSchema() (graphql.Schema, error) { Fields: graphql.Fields{ "ok": &graphql.Field{Type: graphql.Boolean}, "diagnostics": &graphql.Field{Type: graphql.NewList(diagnosticType)}, + "checked": &graphql.Field{Type: graphql.Int}, }, }) snapshotItemType := graphql.NewObject(graphql.ObjectConfig{ @@ -427,71 +390,50 @@ func (r *Runtime) newSchema() (graphql.Schema, error) { "type": &graphql.Field{ Type: graphql.String, Resolve: func(p graphql.ResolveParams) (interface{}, error) { - item := p.Source.(store.SnapshotItem) + item := p.Source.(state.SnapshotItem) return string(item.Type), nil }, }, "field": &graphql.Field{ - Type: graphql.String, + Type: fieldRefType, Resolve: func(p graphql.ResolveParams) (interface{}, error) { - item := p.Source.(store.SnapshotItem) - return item.Field.String(), nil - }, - }, - "fieldTypeID": &graphql.Field{ - Type: graphql.String, - Resolve: func(p graphql.ResolveParams) (interface{}, error) { - item := p.Source.(store.SnapshotItem) - return string(item.Field.TypeID), nil - }, - }, - "fieldInstance": &graphql.Field{ - Type: graphql.String, - Resolve: func(p graphql.ResolveParams) (interface{}, error) { - item := p.Source.(store.SnapshotItem) - return item.Field.Instance, nil - }, - }, - "fieldName": &graphql.Field{ - Type: graphql.String, - Resolve: func(p graphql.ResolveParams) (interface{}, error) { - item := p.Source.(store.SnapshotItem) - return item.Field.Field, nil + item := p.Source.(state.SnapshotItem) + return fieldRefView(item.Field), nil }, }, "source": &graphql.Field{ - Type: graphql.String, + Type: sourceType, Resolve: func(p graphql.ResolveParams) (interface{}, error) { - item := p.Source.(store.SnapshotItem) - return item.Source.Name, nil + item := p.Source.(state.SnapshotItem) + return item.Source, nil }, }, "origin": &graphql.Field{ - Type: graphql.String, + Type: sourceType, Resolve: func(p graphql.ResolveParams) (interface{}, error) { - item := p.Source.(store.SnapshotItem) - return item.Origin.Name, nil + item := p.Source.(state.SnapshotItem) + return item.Origin, nil }, }, "explicit": &graphql.Field{Type: graphql.Boolean}, "confidence": &graphql.Field{ Type: graphql.String, Resolve: func(p graphql.ResolveParams) (interface{}, error) { - item := p.Source.(store.SnapshotItem) + item := p.Source.(state.SnapshotItem) return string(item.Confidence), nil }, }, "visibility": &graphql.Field{ Type: graphql.String, Resolve: func(p graphql.ResolveParams) (interface{}, error) { - item := p.Source.(store.SnapshotItem) + item := p.Source.(state.SnapshotItem) return string(item.Visibility), nil }, }, "exposure": &graphql.Field{ Type: graphql.String, Resolve: func(p graphql.ResolveParams) (interface{}, error) { - item := p.Source.(store.SnapshotItem) + item := p.Source.(state.SnapshotItem) return string(item.Exposure), nil }, }, @@ -500,7 +442,7 @@ func (r *Runtime) newSchema() (graphql.Schema, error) { "updatedAt": &graphql.Field{ Type: graphql.String, Resolve: func(p graphql.ResolveParams) (interface{}, error) { - item := p.Source.(store.SnapshotItem) + item := p.Source.(state.SnapshotItem) return timeString(item.UpdatedAt), nil }, }, @@ -509,6 +451,29 @@ func (r *Runtime) newSchema() (graphql.Schema, error) { var environmentType *graphql.Object + assistType := graphql.NewObject(graphql.ObjectConfig{ + Name: "Assist", + Fields: graphql.FieldsThunk(func() graphql.Fields { + return graphql.Fields{ + "typeSuggestions": &graphql.Field{ + Type: typeResultType, + Args: graphql.FieldConfigArgument{ + "all": &graphql.ArgumentConfig{Type: graphql.Boolean, DefaultValue: false}, + }, + Resolve: func(p graphql.ResolveParams) (interface{}, error) { + gctx := p.Source.(GraphContext) + all, _ := p.Args["all"].(bool) + result, err := state.MachineFromState(gctx.State, gctx.Types).Type(state.TypePolicy{All: all}) + if err != nil { + return nil, err + } + return typeResultView(result), nil + }, + }, + } + }), + }) + renderType := graphql.NewObject(graphql.ObjectConfig{ Name: "Render", Fields: graphql.FieldsThunk(func() graphql.Fields { @@ -519,9 +484,9 @@ func (r *Runtime) newSchema() (graphql.Schema, error) { "reveal": &graphql.ArgumentConfig{Type: graphql.Boolean, DefaultValue: false}, }, Resolve: func(p graphql.ResolveParams) (interface{}, error) { - gctx := p.Source.(Context) + gctx := p.Source.(GraphContext) reveal, _ := p.Args["reveal"].(bool) - return store.NewState(gctx.State, gctx.Types).Snapshot(store.SnapshotPolicy{Reveal: reveal}) + return state.MachineFromState(gctx.State, gctx.Types).Snapshot(state.SnapshotPolicy{Reveal: reveal}) }, }, "dotenv": &graphql.Field{ @@ -530,20 +495,27 @@ func (r *Runtime) newSchema() (graphql.Schema, error) { "insecure": &graphql.ArgumentConfig{Type: graphql.Boolean, DefaultValue: false}, }, Resolve: func(p graphql.ResolveParams) (interface{}, error) { - gctx := p.Source.(Context) + gctx := p.Source.(GraphContext) insecure, _ := p.Args["insecure"].(bool) - return store.NewState(gctx.State, gctx.Types).Dotenv(store.DotenvPolicy{Insecure: insecure}) + return state.MachineFromState(gctx.State, gctx.Types).Dotenv(state.DotenvPolicy{Insecure: insecure}) }, }, "check": &graphql.Field{ Type: checkType, Resolve: func(p graphql.ResolveParams) (interface{}, error) { - gctx := p.Source.(Context) - check := store.NewState(gctx.State, gctx.Types).Check() + gctx := p.Source.(GraphContext) + check := state.MachineFromState(gctx.State, gctx.Types).Check() check.Diagnostics = sortedDiagnostics(check.Diagnostics) return check, nil }, }, + "dotenvSpec": &graphql.Field{ + Type: graphql.String, + Resolve: func(p graphql.ResolveParams) (interface{}, error) { + gctx := p.Source.(GraphContext) + return requirements.RenderDotenvSpec(contractsFromState(gctx.State), gctx.Types) + }, + }, "get": &graphql.Field{ Type: getResultType, Args: graphql.FieldConfigArgument{ @@ -551,10 +523,10 @@ func (r *Runtime) newSchema() (graphql.Schema, error) { "reveal": &graphql.ArgumentConfig{Type: graphql.Boolean, DefaultValue: false}, }, Resolve: func(p graphql.ResolveParams) (interface{}, error) { - gctx := p.Source.(Context) + gctx := p.Source.(GraphContext) key := p.Args["key"].(string) reveal, _ := p.Args["reveal"].(bool) - result, ok, err := store.NewState(gctx.State, gctx.Types).Get(key, store.GetPolicy{Reveal: reveal}) + result, ok, err := state.MachineFromState(gctx.State, gctx.Types).Get(key, state.GetPolicy{Reveal: reveal}) if err != nil || !ok { return nil, err } @@ -564,8 +536,8 @@ func (r *Runtime) newSchema() (graphql.Schema, error) { "sensitiveKeys": &graphql.Field{ Type: graphql.NewList(graphql.String), Resolve: func(p graphql.ResolveParams) (interface{}, error) { - gctx := p.Source.(Context) - return store.NewState(gctx.State, gctx.Types).SensitiveKeys() + gctx := p.Source.(GraphContext) + return state.MachineFromState(gctx.State, gctx.Types).SensitiveKeys() }, }, } @@ -583,13 +555,13 @@ func (r *Runtime) newSchema() (graphql.Schema, error) { }, Resolve: func(p graphql.ResolveParams) (interface{}, error) { input := decodeLoadInput(p.Args["input"].(map[string]interface{})) - gctx := p.Source.(Context) - s := store.NewState(gctx.State, gctx.Types) - state, err := s.Apply(contextFromParams(p), store.LoadOperation{Input: input, Timestamp: input.Timestamp}) + gctx := p.Source.(GraphContext) + s := state.MachineFromState(gctx.State, gctx.Types) + state, err := s.Apply(contextFromParams(p), state.LoadOperation{Input: input, Timestamp: input.Timestamp}) if err != nil { return nil, err } - return Context{State: state, Types: gctx.Types}, nil + return GraphContext{State: state, Types: gctx.Types}, nil }, }, "update": &graphql.Field{ @@ -598,14 +570,14 @@ func (r *Runtime) newSchema() (graphql.Schema, error) { "dotenv": &graphql.ArgumentConfig{Type: dotenvInput}, }, Resolve: func(p graphql.ResolveParams) (interface{}, error) { - gctx := p.Source.(Context) + gctx := p.Source.(GraphContext) input := decodeDotenvInput(p.Args["dotenv"]) - s := store.NewState(gctx.State, gctx.Types) - state, err := s.Apply(contextFromParams(p), store.UpdateOperation{Source: input.DotenvSource, Dotenv: input.Dotenv, Timestamp: input.Timestamp}) + s := state.MachineFromState(gctx.State, gctx.Types) + state, err := s.Apply(contextFromParams(p), state.UpdateOperation{Source: input.DotenvSource, Dotenv: input.Dotenv, Timestamp: input.Timestamp}) if err != nil { return nil, err } - return Context{State: state, Types: gctx.Types}, nil + return GraphContext{State: state, Types: gctx.Types}, nil }, }, "delete": &graphql.Field{ @@ -614,13 +586,13 @@ func (r *Runtime) newSchema() (graphql.Schema, error) { "keys": &graphql.ArgumentConfig{Type: graphql.NewList(graphql.NewNonNull(graphql.String))}, }, Resolve: func(p graphql.ResolveParams) (interface{}, error) { - gctx := p.Source.(Context) - s := store.NewState(gctx.State, gctx.Types) - state, err := s.Apply(contextFromParams(p), store.DeleteOperation{Keys: decodeStringList(p.Args["keys"])}) + gctx := p.Source.(GraphContext) + s := state.MachineFromState(gctx.State, gctx.Types) + state, err := s.Apply(contextFromParams(p), state.DeleteOperation{Keys: decodeStringList(p.Args["keys"])}) if err != nil { return nil, err } - return Context{State: state, Types: gctx.Types}, nil + return GraphContext{State: state, Types: gctx.Types}, nil }, }, "recordResolverAttempt": &graphql.Field{ @@ -629,14 +601,14 @@ func (r *Runtime) newSchema() (graphql.Schema, error) { "attempt": &graphql.ArgumentConfig{Type: graphql.NewNonNull(resolverAttemptInput)}, }, Resolve: func(p graphql.ResolveParams) (interface{}, error) { - gctx := p.Source.(Context) + gctx := p.Source.(GraphContext) attempt := decodeResolverAttemptInput(p.Args["attempt"]) - s := store.NewState(gctx.State, gctx.Types) - state, err := s.Apply(contextFromParams(p), store.RecordResolverAttemptOperation{Attempt: attempt}) + s := state.MachineFromState(gctx.State, gctx.Types) + state, err := s.Apply(contextFromParams(p), state.RecordResolverAttemptOperation{Attempt: attempt}) if err != nil { return nil, err } - return Context{State: state, Types: gctx.Types}, nil + return GraphContext{State: state, Types: gctx.Types}, nil }, }, "applyResolverProposal": &graphql.Field{ @@ -646,41 +618,41 @@ func (r *Runtime) newSchema() (graphql.Schema, error) { "timestamp": &graphql.ArgumentConfig{Type: graphql.String}, }, Resolve: func(p graphql.ResolveParams) (interface{}, error) { - gctx := p.Source.(Context) + gctx := p.Source.(GraphContext) proposal := decodeResolverProposalInput(p.Args["proposal"]) - s := store.NewState(gctx.State, gctx.Types) - state, err := s.Apply(contextFromParams(p), store.ApplyResolverProposalOperation{ + s := state.MachineFromState(gctx.State, gctx.Types) + state, err := s.Apply(contextFromParams(p), state.ApplyResolverProposalOperation{ Proposal: proposal, Timestamp: timeValue(p.Args["timestamp"]), }) if err != nil { return nil, err } - return Context{State: state, Types: gctx.Types}, nil + return GraphContext{State: state, Types: gctx.Types}, nil }, }, "normalize": &graphql.Field{ Type: environmentType, Resolve: func(p graphql.ResolveParams) (interface{}, error) { - gctx := p.Source.(Context) - s := store.NewState(gctx.State, gctx.Types) - state, err := s.Apply(contextFromParams(p), store.NormalizeOperation{}) + gctx := p.Source.(GraphContext) + s := state.MachineFromState(gctx.State, gctx.Types) + state, err := s.Apply(contextFromParams(p), state.NormalizeOperation{}) if err != nil { return nil, err } - return Context{State: state, Types: gctx.Types}, nil + return GraphContext{State: state, Types: gctx.Types}, nil }, }, "validate": &graphql.Field{ Type: environmentType, Resolve: func(p graphql.ResolveParams) (interface{}, error) { - gctx := p.Source.(Context) - s := store.NewState(gctx.State, gctx.Types) - state, err := s.Apply(contextFromParams(p), store.IntegrityOperation{Types: gctx.Types}) + gctx := p.Source.(GraphContext) + s := state.MachineFromState(gctx.State, gctx.Types) + state, err := s.Apply(contextFromParams(p), state.IntegrityOperation{Types: gctx.Types}) if err != nil { return nil, err } - return Context{State: state, Types: gctx.Types}, nil + return GraphContext{State: state, Types: gctx.Types}, nil }, }, "render": &graphql.Field{ @@ -695,6 +667,12 @@ func (r *Runtime) newSchema() (graphql.Schema, error) { return p.Source, nil }, }, + "assist": &graphql.Field{ + Type: assistType, + Resolve: func(p graphql.ResolveParams) (interface{}, error) { + return p.Source, nil + }, + }, } }), }) @@ -705,7 +683,7 @@ func (r *Runtime) newSchema() (graphql.Schema, error) { "Environment": &graphql.Field{ Type: environmentType, Resolve: func(p graphql.ResolveParams) (interface{}, error) { - return Context{State: model.NewEffectiveState(), Types: r.types}, nil + return GraphContext{State: model.NewEffectiveState(), Types: r.types}, nil }, }, }, @@ -721,10 +699,50 @@ func contextFromParams(p graphql.ResolveParams) context.Context { return p.Context } -func decodeLoadInput(raw map[string]interface{}) store.LoadInput { - var input store.LoadInput +func contractsFromState(effective model.EffectiveState) []state.EnvContract { + type contractKey struct { + source model.Source + projection model.ProjectionID + } + positions := make(map[contractKey]int) + var contracts []state.EnvContract + for _, binding := range effective.Bindings { + source := binding.Origin + key := contractKey{source: source, projection: binding.ProjectionID} + position, ok := positions[key] + if !ok { + position = len(contracts) + positions[key] = position + contracts = append(contracts, state.EnvContract{ + Source: source, + Projection: binding.ProjectionID, + }) + } + contracts[position].Bindings = append(contracts[position].Bindings, state.EnvBinding{ + FieldRef: binding.FieldRef, + Key: string(binding.Key), + Projection: binding.ProjectionID, + Required: binding.Required, + Description: binding.Description, + Source: source, + Order: binding.Order, + Sensitivity: effective.Values[binding.FieldRef].Sensitivity, + Exposure: effective.Values[binding.FieldRef].Exposure, + }) + } + sort.SliceStable(contracts, func(i, j int) bool { + if contracts[i].Source.Name != contracts[j].Source.Name { + return contracts[i].Source.Name < contracts[j].Source.Name + } + return contracts[i].Projection < contracts[j].Projection + }) + return contracts +} + +func decodeLoadInput(raw map[string]interface{}) state.LoadInput { + var input state.LoadInput if envelopeRaw, ok := raw["envelope"].(map[string]interface{}); ok { - envelope := store.StateEnvelope{ + envelope := state.StateEnvelope{ ModelVersion: stringValue(envelopeRaw["modelVersion"]), State: decodeEffectiveStateInput(envelopeRaw["state"]), Provenance: decodeStateProvenanceInput(envelopeRaw["provenance"]), @@ -740,7 +758,7 @@ func decodeLoadInput(raw map[string]interface{}) store.LoadInput { if !ok { continue } - contract := store.EnvContract{ + contract := state.EnvContract{ Source: decodeSource(contractRaw["source"]), Projection: model.ProjectionID(stringValue(contractRaw["projection"])), } @@ -749,7 +767,7 @@ func decodeLoadInput(raw map[string]interface{}) store.LoadInput { if !ok { continue } - contract.Bindings = append(contract.Bindings, store.EnvBinding{ + contract.Bindings = append(contract.Bindings, state.EnvBinding{ FieldRef: decodeFieldRef(bindingRaw["field"]), Key: stringValue(bindingRaw["key"]), Projection: model.ProjectionID(stringValue(bindingRaw["projection"])), @@ -769,8 +787,8 @@ func decodeLoadInput(raw map[string]interface{}) store.LoadInput { return input } -func decodeDotenvInput(raw interface{}) store.LoadInput { - var input store.LoadInput +func decodeDotenvInput(raw interface{}) state.LoadInput { + var input state.LoadInput dotenvRaw, ok := raw.(map[string]interface{}) if !ok { return input @@ -782,7 +800,7 @@ func decodeDotenvInput(raw interface{}) store.LoadInput { if !ok { continue } - input.Dotenv = append(input.Dotenv, store.DotenvVariable{ + input.Dotenv = append(input.Dotenv, state.DotenvVariable{ Key: stringValue(variable["key"]), Value: stringValue(variable["value"]), Source: decodeSource(variable["source"]), @@ -934,8 +952,8 @@ func decodeDiagnosticsInput(raw interface{}) []model.Diagnostic { return diagnostics } -func decodeStateProvenanceInput(raw interface{}) store.StateProvenance { - var provenance store.StateProvenance +func decodeStateProvenanceInput(raw interface{}) state.StateProvenance { + var provenance state.StateProvenance row, ok := raw.(map[string]interface{}) if !ok { return provenance @@ -1051,6 +1069,20 @@ func uintValue(raw interface{}) uint { return 0 } +func intValue(raw interface{}) int { + switch value := raw.(type) { + case int: + return value + case int32: + return int(value) + case int64: + return int(value) + case float64: + return int(value) + } + return 0 +} + func timeString(value time.Time) string { if value.IsZero() { return "" @@ -1070,7 +1102,7 @@ func timeValue(raw interface{}) time.Time { return parsed } -func stateEnvelopeView(envelope store.StateEnvelope) map[string]interface{} { +func stateEnvelopeView(envelope state.StateEnvelope) map[string]interface{} { return map[string]interface{}{ "modelVersion": envelope.ModelVersion, "state": effectiveStateView(envelope.State), @@ -1218,7 +1250,7 @@ func unresolvedFrontierView(frontier model.UnresolvedFrontier) map[string]interf return map[string]interface{}{"needs": needs} } -func getResultView(result store.GetResult) map[string]interface{} { +func getResultView(result state.GetResult) map[string]interface{} { return map[string]interface{}{ "key": result.Key, "field": fieldRefView(result.Field), @@ -1230,6 +1262,22 @@ func getResultView(result store.GetResult) map[string]interface{} { } } +func typeResultView(result state.TypeResult) map[string]interface{} { + proposals := make([]map[string]interface{}, 0, len(result.Proposals)) + for _, proposal := range result.Proposals { + proposals = append(proposals, map[string]interface{}{ + "key": proposal.Key, + "currentType": string(proposal.CurrentType), + "suggestedType": string(proposal.SuggestedType), + "confidence": string(proposal.Confidence), + "reason": proposal.Reason, + "description": proposal.Description, + "required": proposal.Required, + }) + } + return map[string]interface{}{"proposals": proposals} +} + func sourceView(source model.Source) map[string]interface{} { return map[string]interface{}{ "name": source.Name, diff --git a/internal/requirements/config.go b/internal/requirements/config.go index bfd2cce..bc090e2 100644 --- a/internal/requirements/config.go +++ b/internal/requirements/config.go @@ -8,7 +8,7 @@ import ( "github.com/runmedev/owl/internal/model" "github.com/runmedev/owl/internal/registry" - "github.com/runmedev/owl/internal/store" + "github.com/runmedev/owl/internal/state" ) type ConfigCompiler struct { @@ -38,11 +38,11 @@ func NewConfigCompiler(types registry.TypeProvider, source model.Source) *Config } } -func ContractsFromConfig(input model.ConfigInput, source model.Source, types registry.TypeProvider) ([]store.EnvContract, error) { +func ContractsFromConfig(input model.ConfigInput, source model.Source, types registry.TypeProvider) ([]state.EnvContract, error) { return NewConfigCompiler(types, source).Compile(input) } -func (c *ConfigCompiler) Compile(input model.ConfigInput) ([]store.EnvContract, error) { +func (c *ConfigCompiler) Compile(input model.ConfigInput) ([]state.EnvContract, error) { if len(input.Needs) == 0 { return nil, nil } @@ -53,7 +53,7 @@ func (c *ConfigCompiler) Compile(input model.ConfigInput) ([]store.EnvContract, } c.instanceCounts = instanceCounts - contracts := make([]store.EnvContract, 0, len(input.Needs)) + contracts := make([]state.EnvContract, 0, len(input.Needs)) for i, need := range input.Needs { contract, err := c.compileNeed(fmt.Sprintf("needs[%d]", i), need) if err != nil { @@ -96,52 +96,52 @@ func (c *ConfigCompiler) countInstances(input model.ConfigInput) (map[model.Type return counts, nil } -func (c *ConfigCompiler) compileNeed(path string, need model.NeedInput) (store.EnvContract, error) { +func (c *ConfigCompiler) compileNeed(path string, need model.NeedInput) (state.EnvContract, error) { typeRef := strings.TrimSpace(string(need.Type)) if typeRef == "" { - return store.EnvContract{}, fmt.Errorf("%s.type: type is required", path) + return state.EnvContract{}, fmt.Errorf("%s.type: type is required", path) } instance := strings.TrimSpace(need.Instance) if instance == "" { - return store.EnvContract{}, fmt.Errorf("%s.instance: instance is required", path) + return state.EnvContract{}, fmt.Errorf("%s.instance: instance is required", path) } typeDef, ok, err := c.types.ResolveTypeRef(typeRef) if err != nil { - return store.EnvContract{}, fmt.Errorf("%s.type: %w", path, err) + return state.EnvContract{}, fmt.Errorf("%s.type: %w", path, err) } if !ok { - return store.EnvContract{}, fmt.Errorf("%s.type: unknown type %q", path, typeRef) + return state.EnvContract{}, fmt.Errorf("%s.type: unknown type %q", path, typeRef) } fieldKeys, err := c.dotenvFieldKeys(path, need, typeDef) if err != nil { - return store.EnvContract{}, err + return state.EnvContract{}, err } fields := sortedFieldNames(fieldKeys) - contract := store.EnvContract{ + contract := state.EnvContract{ Source: c.source, Projection: model.ProjectionDotenv, } for _, fieldName := range fields { binding, err := c.compileBinding(path, typeDef, instance, fieldName, fieldKeys[fieldName]) if err != nil { - return store.EnvContract{}, err + return state.EnvContract{}, err } contract.Bindings = append(contract.Bindings, binding) } return contract, nil } -func (c *ConfigCompiler) compileBinding(path string, typeDef model.TypeDef, instance string, fieldName string, key string) (store.EnvBinding, error) { +func (c *ConfigCompiler) compileBinding(path string, typeDef model.TypeDef, instance string, fieldName string, key string) (state.EnvBinding, error) { fieldDef := typeDef.Fields[fieldName] ref := model.FieldRef{TypeID: typeDef.ID, Instance: instance, Field: fieldName} if previous, ok := c.seenKeys[key]; ok && previous != ref { - return store.EnvBinding{}, fmt.Errorf("%s.dotenv.%s: duplicate dotenv key %q already bound to %s", path, fieldName, key, previous.String()) + return state.EnvBinding{}, fmt.Errorf("%s.dotenv.%s: duplicate dotenv key %q already bound to %s", path, fieldName, key, previous.String()) } c.seenKeys[key] = ref c.order++ - return store.EnvBinding{ + return state.EnvBinding{ FieldRef: ref, Key: key, Projection: model.ProjectionDotenv, diff --git a/internal/requirements/config_test.go b/internal/requirements/config_test.go index 5ef11de..0e3ab4e 100644 --- a/internal/requirements/config_test.go +++ b/internal/requirements/config_test.go @@ -9,7 +9,7 @@ import ( "github.com/runmedev/owl/internal/model" "github.com/runmedev/owl/internal/registry" - "github.com/runmedev/owl/internal/store" + "github.com/runmedev/owl/internal/state" ) func TestContractsFromConfigInfersRequiredDotenvBindings(t *testing.T) { @@ -304,8 +304,8 @@ func TestRenderDotenvSpec(t *testing.T) { }, "\n"), rendered) } -func bindingsByKey(bindings []store.EnvBinding) map[string]store.EnvBinding { - result := make(map[string]store.EnvBinding, len(bindings)) +func bindingsByKey(bindings []state.EnvBinding) map[string]state.EnvBinding { + result := make(map[string]state.EnvBinding, len(bindings)) for _, binding := range bindings { result[binding.Key] = binding } diff --git a/internal/requirements/dotenv_spec.go b/internal/requirements/dotenv_spec.go index aaec862..cadd055 100644 --- a/internal/requirements/dotenv_spec.go +++ b/internal/requirements/dotenv_spec.go @@ -7,14 +7,14 @@ import ( "github.com/runmedev/owl/internal/model" "github.com/runmedev/owl/internal/registry" - "github.com/runmedev/owl/internal/store" + "github.com/runmedev/owl/internal/state" ) const GeneratedDotenvSpecHeaderPrefix = "# Generated by Owl from " const GeneratedDotenvSpecHeader = GeneratedDotenvSpecHeaderPrefix + "Owl config. Do not edit by hand." -func RenderDotenvSpec(contracts []store.EnvContract, types registry.TypeProvider) (string, error) { +func RenderDotenvSpec(contracts []state.EnvContract, types registry.TypeProvider) (string, error) { if len(contracts) == 0 { return GeneratedDotenvSpecHeader + "\n", nil } @@ -22,7 +22,7 @@ func RenderDotenvSpec(contracts []store.EnvContract, types registry.TypeProvider types = registry.NewBuiltInRegistry() } - var bindings []store.EnvBinding + var bindings []state.EnvBinding for _, contract := range contracts { bindings = append(bindings, contract.Bindings...) } @@ -71,7 +71,7 @@ func RenderDotenvSpec(contracts []store.EnvContract, types registry.TypeProvider return b.String(), nil } -func generatedDotenvSpecHeader(contracts []store.EnvContract) string { +func generatedDotenvSpecHeader(contracts []state.EnvContract) string { source := "Owl config" if len(contracts) > 0 { name := strings.TrimSpace(contracts[0].Source.Name) diff --git a/internal/resolver/resolver_test.go b/internal/resolver/resolver_test.go index 0e0f0be..e7fc731 100644 --- a/internal/resolver/resolver_test.go +++ b/internal/resolver/resolver_test.go @@ -156,7 +156,7 @@ func TestResolverPackageDoesNotImportStoreOrGraph(t *testing.T) { require.NoError(t, err) for _, imported := range file.Imports { unquoted := strings.Trim(imported.Path.Value, `"`) - assert.NotEqual(t, "github.com/runmedev/owl/internal/store", unquoted) + assert.NotEqual(t, "github.com/runmedev/owl/internal/state", unquoted) assert.NotEqual(t, "github.com/runmedev/owl/internal/graph", unquoted) } } diff --git a/internal/seed/seed_test.go b/internal/seed/seed_test.go index 445bb08..9f030c8 100644 --- a/internal/seed/seed_test.go +++ b/internal/seed/seed_test.go @@ -16,6 +16,23 @@ import ( "github.com/runmedev/owl/pkg/owl" ) +func snapshotItems(t *testing.T, store *owl.Store, policy owl.SnapshotPolicy) []owl.SnapshotItem { + t.Helper() + output, err := store.Snapshot(context.Background(), owl.SnapshotInput{ + Policy: policy, + Filter: owl.SnapshotFilter{All: true}, + }) + require.NoError(t, err) + return output.Envs +} + +func checkStore(t *testing.T, store *owl.Store) owl.CheckOutput { + t.Helper() + output, err := store.Check(context.Background(), owl.CheckInput{}) + require.NoError(t, err) + return output +} + func TestNewStoreSeedsObservedSourceWithCallerProvenance(t *testing.T) { t.Parallel() @@ -40,8 +57,7 @@ func TestNewStoreSeedsObservedSourceWithCallerProvenance(t *testing.T) { }) require.NoError(t, err) - items, err := result.Store.Snapshot(owl.SnapshotPolicy{}) - require.NoError(t, err) + items := snapshotItems(t, result.Store, owl.SnapshotPolicy{}) env := snapshotItemByName(items)["KERNEL_ONLY"] assert.Equal(t, "[hidden]", env.Value) assert.Equal(t, owl.Source{Name: "[kernel]", Kind: "runme-kernel"}, env.Source) @@ -78,8 +94,7 @@ func TestNewStoreAttributesMatchingObservedEnvToDirenv(t *testing.T) { }) require.NoError(t, err) - items, err := result.Store.Snapshot(owl.SnapshotPolicy{}) - require.NoError(t, err) + items := snapshotItems(t, result.Store, owl.SnapshotPolicy{}) env := snapshotItemByName(items)["DIRENV_WINS"] assert.Equal(t, "from-direnv", env.Value) assert.Equal(t, owl.Source{Name: ".envrc", Kind: "direnv"}, env.Source) @@ -111,8 +126,7 @@ func TestNewStoreDefaultsDirenvToWarn(t *testing.T) { }) require.NoError(t, err) - items, err := result.Store.Snapshot(owl.SnapshotPolicy{}) - require.NoError(t, err) + items := snapshotItems(t, result.Store, owl.SnapshotPolicy{}) env := snapshotItemByName(items)["DIRENV_DEFAULT"] assert.Equal(t, "from-direnv", env.Value) assert.Equal(t, owl.Source{Name: ".envrc", Kind: "direnv"}, env.Source) @@ -215,8 +229,7 @@ func TestNewRawValueStoreSkipsMissingEnvFiles(t *testing.T) { }, false) require.NoError(t, err) - items, err := store.Snapshot(owl.SnapshotPolicy{Reveal: true}) - require.NoError(t, err) + items := snapshotItems(t, store, owl.SnapshotPolicy{Reveal: true}) env := snapshotItemByName(items)["PRESENT"] assert.Equal(t, "from-dotenv", env.Value) assert.Equal(t, owl.VisibilityLiteral, env.Visibility) @@ -236,8 +249,7 @@ func TestNewRawValueStoreUsesDefaultEnvFilesInOrder(t *testing.T) { store, err := NewRawValueStore(Options{WorkDir: dir}, false) require.NoError(t, err) - items, err := store.Snapshot(owl.SnapshotPolicy{Reveal: true}) - require.NoError(t, err) + items := snapshotItems(t, store, owl.SnapshotPolicy{Reveal: true}) env := snapshotItemByName(items)["DEFAULT_ORDER"] assert.Equal(t, "from-env-dev", env.Value) assert.Equal(t, ".env.dev", env.Source.Name) @@ -307,7 +319,7 @@ func TestStoreBuildersUseConfiguredTypeProvider(t *testing.T) { require.NoError(t, err) _, err = result.Store.Resolve(context.Background(), owl.ResolveInput{Process: result.Catalog.ProcessResolverInput()}) require.NoError(t, err) - _ = result.Store.Check() + _ = checkStore(t, result.Store) assert.Positive(t, seededProvider.validations) rawProvider := &seedTrackingTypeProvider{BuiltInRegistry: registry.NewBuiltInRegistry()} @@ -317,7 +329,7 @@ func TestStoreBuildersUseConfiguredTypeProvider(t *testing.T) { TypeProvider: rawProvider, }, false) require.NoError(t, err) - _ = rawStore.Check() + _ = checkStore(t, rawStore) assert.Positive(t, rawProvider.validations) } diff --git a/internal/seed/store.go b/internal/seed/store.go index 37f30e4..19c871b 100644 --- a/internal/seed/store.go +++ b/internal/seed/store.go @@ -2,6 +2,7 @@ package seed import ( "bytes" + "context" "errors" "os" "sort" @@ -99,12 +100,14 @@ func seedInheritedValues(store *owl.Store, catalog Catalog) error { } func explicitSnapshotKeys(store *owl.Store) (map[string]struct{}, error) { - items, err := store.Snapshot(owl.SnapshotPolicy{}) + snapshot, err := store.Snapshot(context.Background(), owl.SnapshotInput{ + Filter: owl.SnapshotFilter{All: true}, + }) if err != nil { return nil, err } - keys := make(map[string]struct{}, len(items)) - for _, item := range items { + keys := make(map[string]struct{}, len(snapshot.Envs)) + for _, item := range snapshot.Envs { if item.Explicit { keys[item.Name] = struct{}{} } @@ -133,7 +136,10 @@ func loadInheritedVariables(store *owl.Store, vars []owl.DotenvVariable, explici groups[index].vars = append(groups[index].vars, variable) } for _, group := range groups { - if err := store.LoadDotenv(group.source, group.vars); err != nil { + if err := store.ApplyUpdate(context.Background(), owl.UpdateInput{ + Source: group.source, + Dotenv: group.vars, + }); err != nil { return err } } @@ -141,12 +147,14 @@ func loadInheritedVariables(store *owl.Store, vars []owl.DotenvVariable, explici } func projectionKeys(store *owl.Store) map[string]struct{} { - items, err := store.Snapshot(owl.SnapshotPolicy{}) + snapshot, err := store.Snapshot(context.Background(), owl.SnapshotInput{ + Filter: owl.SnapshotFilter{All: true}, + }) if err != nil { return nil } - keys := make(map[string]struct{}, len(items)) - for _, item := range items { + keys := make(map[string]struct{}, len(snapshot.Envs)) + for _, item := range snapshot.Envs { if item.Name != "" { keys[item.Name] = struct{}{} } diff --git a/internal/store/diagnostics.go b/internal/state/diagnostics.go similarity index 99% rename from internal/store/diagnostics.go rename to internal/state/diagnostics.go index b58f2a1..adafba2 100644 --- a/internal/store/diagnostics.go +++ b/internal/state/diagnostics.go @@ -1,4 +1,4 @@ -package store +package state import ( "fmt" diff --git a/internal/state/doc.go b/internal/state/doc.go new file mode 100644 index 0000000..ce43ddb --- /dev/null +++ b/internal/state/doc.go @@ -0,0 +1,2 @@ +// Package state owns Owl's EffectiveState machine, projections, and envelopes. +package state diff --git a/internal/store/frontier.go b/internal/state/frontier.go similarity index 99% rename from internal/store/frontier.go rename to internal/state/frontier.go index e96f81b..2941ec7 100644 --- a/internal/store/frontier.go +++ b/internal/state/frontier.go @@ -1,4 +1,4 @@ -package store +package state import ( "sort" diff --git a/internal/store/integrity.go b/internal/state/integrity.go similarity index 99% rename from internal/store/integrity.go rename to internal/state/integrity.go index 5a6c3cb..a5959d3 100644 --- a/internal/store/integrity.go +++ b/internal/state/integrity.go @@ -1,4 +1,4 @@ -package store +package state import ( "fmt" diff --git a/internal/store/store.go b/internal/state/store.go similarity index 90% rename from internal/store/store.go rename to internal/state/store.go index 88c7e31..07681a7 100644 --- a/internal/store/store.go +++ b/internal/state/store.go @@ -1,4 +1,4 @@ -package store +package state import ( "context" @@ -16,7 +16,7 @@ import ( "github.com/runmedev/owl/internal/resolver/builtin" ) -type Store struct { +type Machine struct { types registry.TypeProvider state model.EffectiveState operations []OperationRecord @@ -31,7 +31,7 @@ type RecordedOperation interface { Record() OperationRecord } -type StoreOption func(*config) error +type MachineOption func(*config) error type config struct { envs []sourceInput @@ -100,6 +100,7 @@ type SnapshotItem struct { type CheckResult struct { OK bool Diagnostics []model.Diagnostic + Checked int } type GetPolicy struct { @@ -326,7 +327,7 @@ func withoutDiagnosticOwner(diagnostics []model.Diagnostic, owner model.Diagnost return filtered } -func NewStore(opts ...StoreOption) (*Store, error) { +func NewMachine(opts ...MachineOption) (*Machine, error) { cfg := config{} for _, opt := range opts { if err := opt(&cfg); err != nil { @@ -343,22 +344,22 @@ func NewStore(opts ...StoreOption) (*Store, error) { return nil, err } - store := &Store{types: cfg.types, state: model.NewEffectiveState()} - if _, err := store.Apply(context.Background(), LoadOperation{Input: load}); err != nil { + m := &Machine{types: cfg.types, state: model.NewEffectiveState()} + if _, err := m.Apply(context.Background(), LoadOperation{Input: load}); err != nil { return nil, err } - if _, err := store.Apply(context.Background(), NormalizeOperation{}); err != nil { + if _, err := m.Apply(context.Background(), NormalizeOperation{}); err != nil { return nil, err } - state, err := store.Apply(context.Background(), IntegrityOperation{Types: cfg.types}) + next, err := m.Apply(context.Background(), IntegrityOperation{Types: cfg.types}) if err != nil { return nil, err } - store.state = state - return store, nil + m.state = next + return m, nil } -func WithDotenv(source string, r io.Reader) StoreOption { +func WithDotenv(source string, r io.Reader) MachineOption { return func(cfg *config) error { input, err := readSource(source, r) if err != nil { @@ -369,7 +370,7 @@ func WithDotenv(source string, r io.Reader) StoreOption { } } -func WithEnvSpec(source string, r io.Reader) StoreOption { +func WithEnvSpec(source string, r io.Reader) MachineOption { return func(cfg *config) error { input, err := readSource(source, r) if err != nil { @@ -380,29 +381,29 @@ func WithEnvSpec(source string, r io.Reader) StoreOption { } } -func WithTypeProvider(types registry.TypeProvider) StoreOption { +func WithTypeProvider(types registry.TypeProvider) MachineOption { return func(cfg *config) error { cfg.types = types return nil } } -func (s *Store) Apply(ctx context.Context, op Operation) (model.EffectiveState, error) { +func (m *Machine) Apply(ctx context.Context, op Operation) (model.EffectiveState, error) { if recorded, ok := op.(RecordedOperation); ok { record, timestamped := timestampRecordedOperation(recorded.Record()) - s.operations = append(s.operations, record) + m.operations = append(m.operations, record) op = timestamped } - state, err := op.Apply(ctx, s.state) + state, err := op.Apply(ctx, m.state) if err != nil { return model.EffectiveState{}, err } - s.state = state + m.state = state return state, nil } -func (s *Store) ResolveSources(ctx context.Context, input ResolveSourcesInput) (resolver.RunResult, error) { - if _, err := s.Apply(ctx, IntegrityOperation{Types: s.types}); err != nil { +func (m *Machine) ResolveSources(ctx context.Context, input ResolveSourcesInput) (resolver.RunResult, error) { + if _, err := m.Apply(ctx, IntegrityOperation{Types: m.types}); err != nil { return resolver.RunResult{}, err } clock := input.Clock @@ -419,7 +420,7 @@ func (s *Store) ResolveSources(ctx context.Context, input ResolveSourcesInput) ( Clock: clock, } result, err := runner.Resolve(ctx, resolver.RunRequest{ - State: s.state, + State: m.state, Policy: input.Policy, Chain: input.Chain, }) @@ -427,28 +428,28 @@ func (s *Store) ResolveSources(ctx context.Context, input ResolveSourcesInput) ( return result, err } for _, attempt := range result.Attempts { - if _, err := s.Apply(ctx, RecordResolverAttemptOperation{Attempt: attempt}); err != nil { + if _, err := m.Apply(ctx, RecordResolverAttemptOperation{Attempt: attempt}); err != nil { return result, err } } for _, proposal := range result.Proposals { - if _, err := s.Apply(ctx, ApplyResolverProposalOperation{Proposal: proposal, Timestamp: input.Timestamp}); err != nil { + if _, err := m.Apply(ctx, ApplyResolverProposalOperation{Proposal: proposal, Timestamp: input.Timestamp}); err != nil { return result, err } } - if _, err := s.Apply(ctx, IntegrityOperation{Types: s.types}); err != nil { + if _, err := m.Apply(ctx, IntegrityOperation{Types: m.types}); err != nil { return result, err } return result, nil } -func (s *Store) ApplyPromptAnswers(ctx context.Context, input ApplyPromptAnswersInput) (resolver.RunResult, error) { +func (m *Machine) ApplyPromptAnswers(ctx context.Context, input ApplyPromptAnswersInput) (resolver.RunResult, error) { newAttemptID := input.NewAttemptID if newAttemptID == nil { - newAttemptID = promptAttemptIDGenerator(len(s.operations)) + newAttemptID = promptAttemptIDGenerator(len(m.operations)) } timestamp := operationTimestamp(input.Timestamp) - needs := needsByID(s.state.UnresolvedFrontier.Needs) + needs := needsByID(m.state.UnresolvedFrontier.Needs) var result resolver.RunResult for _, answer := range input.Answers { need, ok := needs[answer.NeedID] @@ -463,7 +464,7 @@ func (s *Store) ApplyPromptAnswers(ctx context.Context, input ApplyPromptAnswers if !ok { attempt.Outcome = model.ResolverAttemptInvalidResult attempt.Message = "interactive answer references an unknown unresolved need" - if _, err := s.Apply(ctx, RecordResolverAttemptOperation{Attempt: attempt}); err != nil { + if _, err := m.Apply(ctx, RecordResolverAttemptOperation{Attempt: attempt}); err != nil { return result, err } result.Attempts = append(result.Attempts, attempt) @@ -484,16 +485,16 @@ func (s *Store) ApplyPromptAnswers(ctx context.Context, input ApplyPromptAnswers Exposure: need.Exposure, }, } - if _, err := s.Apply(ctx, RecordResolverAttemptOperation{Attempt: attempt}); err != nil { + if _, err := m.Apply(ctx, RecordResolverAttemptOperation{Attempt: attempt}); err != nil { return result, err } - if _, err := s.Apply(ctx, ApplyResolverProposalOperation{Proposal: proposal, Timestamp: timestamp}); err != nil { + if _, err := m.Apply(ctx, ApplyResolverProposalOperation{Proposal: proposal, Timestamp: timestamp}); err != nil { return result, err } result.Attempts = append(result.Attempts, attempt) result.Proposals = append(result.Proposals, proposal) } - if _, err := s.Apply(ctx, IntegrityOperation{Types: s.types}); err != nil { + if _, err := m.Apply(ctx, IntegrityOperation{Types: m.types}); err != nil { return result, err } return result, nil @@ -735,10 +736,10 @@ func (op IntegrityOperation) Apply(_ context.Context, state model.EffectiveState return state, nil } -func (s *Store) Snapshot(policy SnapshotPolicy) ([]SnapshotItem, error) { - items := make([]SnapshotItem, 0, len(s.state.Bindings)) - for _, binding := range s.state.Bindings { - value := s.state.Values[binding.FieldRef] +func (m *Machine) Snapshot(policy SnapshotPolicy) ([]SnapshotItem, error) { + items := make([]SnapshotItem, 0, len(m.state.Bindings)) + for _, binding := range m.state.Bindings { + value := m.state.Values[binding.FieldRef] rendered := renderSnapshotValue(value, policy) original := value.Original if rendered.visibility != model.VisibilityLiteral { @@ -759,7 +760,7 @@ func (s *Store) Snapshot(policy SnapshotPolicy) ([]SnapshotItem, error) { Exposure: value.Exposure, Description: binding.Description, UpdatedAt: value.UpdatedAt, - Diagnostics: diagnosticsFor(s.state.Diagnostics, binding), + Diagnostics: diagnosticsFor(m.state.Diagnostics, binding), }) } sort.SliceStable(items, func(i, j int) bool { @@ -779,12 +780,12 @@ func (s *Store) Snapshot(policy SnapshotPolicy) ([]SnapshotItem, error) { return items, nil } -func (s *Store) Source(policy SourcePolicy) ([]string, error) { - return s.Dotenv(DotenvPolicy(policy)) +func (m *Machine) Source(policy SourcePolicy) ([]string, error) { + return m.Dotenv(DotenvPolicy(policy)) } -func (s *Store) Dotenv(policy DotenvPolicy) ([]string, error) { - rendered := dotenv.RenderDotenvProjection(s.state, model.RenderPolicy{Insecure: policy.Insecure}) +func (m *Machine) Dotenv(policy DotenvPolicy) ([]string, error) { + rendered := dotenv.RenderDotenvProjection(m.state, model.RenderPolicy{Insecure: policy.Insecure}) envs := make([]string, 0, len(rendered.Variables)) for _, variable := range rendered.Variables { envs = append(envs, variable.Key+"="+variable.Value) @@ -793,13 +794,13 @@ func (s *Store) Dotenv(policy DotenvPolicy) ([]string, error) { return envs, nil } -func (s *Store) Type(policy TypePolicy) (TypeResult, error) { - proposals := make([]TypeProposal, 0, len(s.state.Bindings)) - for _, binding := range s.state.Bindings { +func (m *Machine) Type(policy TypePolicy) (TypeResult, error) { + proposals := make([]TypeProposal, 0, len(m.state.Bindings)) + for _, binding := range m.state.Bindings { if binding.Explicit { continue } - value := s.state.Values[binding.FieldRef] + value := m.state.Values[binding.FieldRef] suggested, reason, ok := suggestPrimitiveType(string(binding.Key), value) if !policy.All && !ok { continue @@ -823,12 +824,12 @@ func (s *Store) Type(policy TypePolicy) (TypeResult, error) { return TypeResult{Proposals: proposals}, nil } -func (s *Store) Get(key string, policy GetPolicy) (GetResult, bool, error) { - ref, binding, found := findBinding(s.state.Bindings, key) +func (m *Machine) Get(key string, policy GetPolicy) (GetResult, bool, error) { + ref, binding, found := findBinding(m.state.Bindings, key) if !found { return GetResult{}, false, nil } - value := s.state.Values[ref] + value := m.state.Values[ref] rendered := renderSnapshotValue(value, SnapshotPolicy(policy)) return GetResult{ Key: key, @@ -837,14 +838,14 @@ func (s *Store) Get(key string, policy GetPolicy) (GetResult, bool, error) { Visibility: rendered.visibility, Exposure: value.Exposure, Source: value.Source, - Diagnostics: diagnosticsFor(s.state.Diagnostics, binding), + Diagnostics: diagnosticsFor(m.state.Diagnostics, binding), }, true, nil } -func (s *Store) SensitiveKeys() ([]string, error) { +func (m *Machine) SensitiveKeys() ([]string, error) { var keys []string - for _, binding := range s.state.Bindings { - value := s.state.Values[binding.FieldRef] + for _, binding := range m.state.Bindings { + value := m.state.Values[binding.FieldRef] if value.Sensitivity == model.SensitivitySensitive { keys = append(keys, string(binding.Key)) } @@ -853,10 +854,11 @@ func (s *Store) SensitiveKeys() ([]string, error) { return keys, nil } -func (s *Store) Check() CheckResult { +func (m *Machine) Check() CheckResult { result := CheckResult{ OK: true, - Diagnostics: append([]model.Diagnostic{}, s.state.Diagnostics...), + Diagnostics: append([]model.Diagnostic{}, m.state.Diagnostics...), + Checked: len(m.state.Bindings), } for _, diagnostic := range result.Diagnostics { if diagnostic.Severity == model.DiagnosticError { @@ -867,30 +869,30 @@ func (s *Store) Check() CheckResult { return result } -func (s *Store) State() model.EffectiveState { - return s.state +func (m *Machine) State() model.EffectiveState { + return m.state } -func (s *Store) OperationRecords() []OperationRecord { - return append([]OperationRecord{}, s.operations...) +func (m *Machine) OperationRecords() []OperationRecord { + return append([]OperationRecord{}, m.operations...) } -func (s *Store) StateEnvelope() StateEnvelope { +func (m *Machine) StateEnvelope() StateEnvelope { return StateEnvelope{ ModelVersion: "owl.store.v2", - State: s.state, + State: m.state, Provenance: StateProvenance{ - Sources: sourcesFromState(s.state), - Operations: append([]model.OperationMetadata{}, s.state.Operations...), + Sources: sourcesFromState(m.state), + Operations: append([]model.OperationMetadata{}, m.state.Operations...), }, } } -func NewState(state model.EffectiveState, types registry.TypeProvider) *Store { +func MachineFromState(state model.EffectiveState, types registry.TypeProvider) *Machine { if types == nil { types = registry.NewBuiltInRegistry() } - return &Store{types: types, state: state} + return &Machine{types: types, state: state} } func readSource(name string, r io.Reader) (sourceInput, error) { diff --git a/internal/store/store_test.go b/internal/state/store_test.go similarity index 96% rename from internal/store/store_test.go rename to internal/state/store_test.go index 8ccf0c9..38c2da4 100644 --- a/internal/store/store_test.go +++ b/internal/state/store_test.go @@ -1,4 +1,4 @@ -package store +package state import ( "context" @@ -18,7 +18,7 @@ import ( func TestStoreSnapshotSourceAndCheck(t *testing.T) { t.Parallel() - s, err := NewStore( + s, err := NewMachine( WithDotenv(".env", strings.NewReader("API_URL=https://api.example.com\nAPI_KEY=secret\nDATABASE_URL=postgres://example\n")), WithEnvSpec(".env.example", strings.NewReader("API_URL=\"API URL\" # Plain\nAPI_KEY=\"API key\" # Secret!\nDATABASE_URL=\"Database URL\" # Opaque\nMISSING_TOKEN=\"Missing token\" # Secret!\n")), ) @@ -55,7 +55,7 @@ func TestStoreSnapshotSourceAndCheck(t *testing.T) { func TestStoreSnapshotOrdersExplicitBindingsBeforeInferredBindings(t *testing.T) { t.Parallel() - s, err := NewStore( + s, err := NewMachine( WithDotenv(".env", strings.NewReader("OMEGA=value\nAPPLE=value\nZETA=value\nBETA=value\n")), WithEnvSpec(".env.example", strings.NewReader("ZETA=\"Zeta\" # Plain\nBETA=\"Beta\" # Plain\n")), ) @@ -74,7 +74,7 @@ func TestStoreSnapshotOrdersExplicitBindingsBeforeInferredBindings(t *testing.T) func TestStoreTypeProposesMissingPrimitiveTypes(t *testing.T) { t.Parallel() - s, err := NewStore( + s, err := NewMachine( WithDotenv(".env", strings.NewReader("API_URL=https://api.example.com\nAPI_KEY=secret\nSERVICE_HOST=localhost\nSERVICE_PORT=8080\nTARGET_PLATFORM=darwin/arm64\n")), WithEnvSpec(".env.spec", strings.NewReader("API_URL=\"API URL\" # Plain\n")), ) @@ -100,7 +100,7 @@ func TestStoreTypeProposesMissingPrimitiveTypes(t *testing.T) { func TestStoreTypeSkipsDefaultPlainProposalsByDefault(t *testing.T) { t.Parallel() - s, err := NewStore( + s, err := NewMachine( WithDotenv(".env", strings.NewReader("API_KEY=secret\nTARGET_PLATFORM=darwin/arm64\n")), ) require.NoError(t, err) @@ -206,7 +206,7 @@ func TestPrimitiveValueDiagnostics(t *testing.T) { func TestStoreWithDotenv(t *testing.T) { t.Parallel() - s, err := NewStore(WithDotenv("[process]", strings.NewReader("REDIS_HOST=localhost\nREDIS_PORT=6379\n"))) + s, err := NewMachine(WithDotenv("[process]", strings.NewReader("REDIS_HOST=localhost\nREDIS_PORT=6379\n"))) require.NoError(t, err) snapshot, err := s.Snapshot(SnapshotPolicy{Reveal: true}) @@ -221,7 +221,7 @@ func TestStoreWithDotenv(t *testing.T) { func TestStorePreservesDotenvValueSource(t *testing.T) { t.Parallel() - s, err := NewStore( + s, err := NewMachine( WithDotenv("[process]", strings.NewReader("PROCESS_ONLY=from-process\nDUPLICATE_KEY=from-process\n")), WithDotenv(".env", strings.NewReader("FILE_ONLY=from-file\nDUPLICATE_KEY=from-file\n")), ) @@ -330,7 +330,7 @@ func TestWithoutDiagnosticOwnerDoesNotReuseInputSlice(t *testing.T) { func TestStoreRecordsFactOperationsOnly(t *testing.T) { t.Parallel() - s, err := NewStore(WithDotenv(".env", strings.NewReader("API_URL=https://api.example.com\n"))) + s, err := NewMachine(WithDotenv(".env", strings.NewReader("API_URL=https://api.example.com\n"))) require.NoError(t, err) records := s.OperationRecords() @@ -357,7 +357,7 @@ func TestStoreRecordsFactOperationsOnly(t *testing.T) { func TestStoreRecordsResolverAttemptWithoutMutatingValues(t *testing.T) { t.Parallel() - s, err := NewStore(WithDotenv(".env", strings.NewReader("API_URL=https://api.example.com\n"))) + s, err := NewMachine(WithDotenv(".env", strings.NewReader("API_URL=https://api.example.com\n"))) require.NoError(t, err) before := s.State() startedAt := time.Date(2026, 8, 3, 16, 0, 0, 0, time.UTC) @@ -392,7 +392,7 @@ func TestStoreRecordsResolverAttemptWithoutMutatingValues(t *testing.T) { func TestStoreAppliesResolverProposalThroughStateOperation(t *testing.T) { t.Parallel() - s, err := NewStore(WithEnvSpec(".env.example", strings.NewReader("API_KEY=\"API key\" # Secret!\n"))) + s, err := NewMachine(WithEnvSpec(".env.example", strings.NewReader("API_KEY=\"API key\" # Secret!\n"))) require.NoError(t, err) before := s.State() require.Len(t, before.UnresolvedFrontier.Needs, 1) @@ -437,7 +437,7 @@ func TestStoreAppliesResolverProposalThroughStateOperation(t *testing.T) { assert.Equal(t, model.ResolverAttemptID("attempt-000001"), records[1].ResolverProposal.AttemptID) } -func TestStoreProposalApplicationLeavesInvalidValuesForIntegrity(t *testing.T) { +func TestMachineProposalApplicationLeavesInvalidValuesForIntegrity(t *testing.T) { t.Parallel() ref := model.FieldRef{TypeID: model.TypeUniverseRedis, Instance: "queues", Field: "port"} @@ -456,7 +456,7 @@ func TestStoreProposalApplicationLeavesInvalidValuesForIntegrity(t *testing.T) { Exposure: model.ExposureClear, } state.UnresolvedFrontier = BuildUnresolvedFrontier(state) - s := NewState(state, registry.NewBuiltInRegistry()) + s := MachineFromState(state, registry.NewBuiltInRegistry()) after, err := s.Apply(context.Background(), ApplyResolverProposalOperation{ Proposal: resolver.Proposal{ @@ -488,7 +488,7 @@ func TestStoreResolveSourcesAppliesDotenvResolverProposals(t *testing.T) { t.Parallel() timestamp := time.Date(2026, 8, 3, 23, 45, 0, 0, time.UTC) - s, err := NewStore(WithEnvSpec(".env.example", strings.NewReader("API_KEY=\"API key\" # Secret!\n"))) + s, err := NewMachine(WithEnvSpec(".env.example", strings.NewReader("API_KEY=\"API key\" # Secret!\n"))) require.NoError(t, err) require.Len(t, s.State().UnresolvedFrontier.Needs, 1) @@ -524,7 +524,7 @@ func TestStoreResolveSourcesAppliesDotenvResolverProposals(t *testing.T) { func TestStoreResolveSourcesUsesDotenvBeforeProcess(t *testing.T) { t.Parallel() - s, err := NewStore(WithEnvSpec(".env.example", strings.NewReader("API_KEY=\"API key\" # Secret!\n"))) + s, err := NewMachine(WithEnvSpec(".env.example", strings.NewReader("API_KEY=\"API key\" # Secret!\n"))) require.NoError(t, err) result, err := s.ResolveSources(context.Background(), ResolveSourcesInput{ @@ -554,7 +554,7 @@ func TestStoreResolveSourcesUsesDotenvBeforeProcess(t *testing.T) { assert.Equal(t, model.Source{Name: ".env", Kind: "dotenv"}, value.Source) } -func TestStoreResolveSourcesKeepsInvalidResolvedValueProvenance(t *testing.T) { +func TestMachineResolveSourcesKeepsInvalidResolvedValueProvenance(t *testing.T) { t.Parallel() ref := model.FieldRef{TypeID: model.TypeUniverseRedis, Instance: "queues", Field: "port"} @@ -566,7 +566,7 @@ func TestStoreResolveSourcesKeepsInvalidResolvedValueProvenance(t *testing.T) { Explicit: true, Required: true, }} - s := NewState(state, registry.NewBuiltInRegistry()) + s := MachineFromState(state, registry.NewBuiltInRegistry()) result, err := s.ResolveSources(context.Background(), ResolveSourcesInput{ Dotenv: []DotenvVariable{{ diff --git a/internal/store/doc.go b/internal/store/doc.go deleted file mode 100644 index 5d17fab..0000000 --- a/internal/store/doc.go +++ /dev/null @@ -1,2 +0,0 @@ -// Package store owns Owl's v2 store lifecycle. -package store diff --git a/pkg/owl/api.go b/pkg/owl/api.go index 2ca975f..3a4eb4a 100644 --- a/pkg/owl/api.go +++ b/pkg/owl/api.go @@ -2,9 +2,9 @@ package owl import ( "context" + "encoding/json" "fmt" "io" - "strings" "time" "github.com/runmedev/owl/internal/graph" @@ -13,19 +13,20 @@ import ( "github.com/runmedev/owl/internal/requirements" "github.com/runmedev/owl/internal/resolver" "github.com/runmedev/owl/internal/resolver/builtin" - "github.com/runmedev/owl/internal/store" + "github.com/runmedev/owl/internal/state" ) type ( - SnapshotPolicy = store.SnapshotPolicy - DotenvPolicy = store.DotenvPolicy - TypePolicy = store.TypePolicy - GetPolicy = store.GetPolicy - SnapshotItem = store.SnapshotItem - TypeResult = store.TypeResult - TypeProposal = store.TypeProposal - GetResult = store.GetResult - CheckResult = store.CheckResult + SnapshotPolicy = state.SnapshotPolicy + DotenvPolicy = state.DotenvPolicy + TypePolicy = state.TypePolicy + GetPolicy = state.GetPolicy + SnapshotItem = state.SnapshotItem + SnapshotEnv = state.SnapshotItem + TypeResult = state.TypeResult + TypeProposal = state.TypeProposal + GetResult = state.GetResult + CheckResult = state.CheckResult ResolvePolicy = resolver.Policy ChainConfig = resolver.ChainConfig ResolverConfig = resolver.ResolverConfig @@ -35,17 +36,21 @@ type ( PromptAnswer = resolver.PromptAnswer TypeID = model.TypeID + TypeDef = model.TypeDef + TypeProvider = registry.TypeProvider FieldRef = model.FieldRef ConfigInput = model.ConfigInput NeedInput = model.NeedInput DotenvProjection = model.DotenvProjectionInput DotenvFieldBinding = model.DotenvFieldBindingInput Source = model.Source - DotenvVariable = store.DotenvVariable - EnvContract = store.EnvContract - EnvBinding = store.EnvBinding - StateEnvelope = store.StateEnvelope - StateProvenance = store.StateProvenance + DotenvVariable = state.DotenvVariable + EnvContract = state.EnvContract + EnvBinding = state.EnvBinding + LoadInput = state.LoadInput + StateEnvelope = state.StateEnvelope + StateProvenance = state.StateProvenance + Sensitivity = model.Sensitivity Visibility = model.Visibility Exposure = model.Exposure Diagnostic = model.Diagnostic @@ -63,6 +68,29 @@ type ( ProposedValue = resolver.ProposedValue ) +func NewBuiltInTypeProvider() TypeProvider { + return registry.NewBuiltInRegistry() +} + +func NewTypeProviderFromDirectory(root string) (TypeProvider, error) { + return registry.NewBuiltInRegistryFromDirectory(root) +} + +type TypeCatalogInput struct { + Root string +} + +func TypeProviderFromCatalogInput(input TypeCatalogInput) (TypeProvider, error) { + if input.Root == "" { + return NewBuiltInTypeProvider(), nil + } + return NewTypeProviderFromDirectory(input.Root) +} + +func ReadConfigFile(path string) (ConfigInput, error) { + return requirements.ReadConfigFile(path) +} + const ( TypeCoreOpaque = model.TypeCoreOpaque TypeCorePlain = model.TypeCorePlain @@ -70,6 +98,10 @@ const ( TypeCoreURL = model.TypeCoreURL TypeUniverseRedis = model.TypeUniverseRedis + SensitivityUnknown = model.SensitivityUnknown + SensitivityPlaintext = model.SensitivityPlaintext + SensitivitySensitive = model.SensitivitySensitive + VisibilityLiteral = model.VisibilityLiteral VisibilityUnresolved = model.VisibilityUnresolved VisibilityMasked = model.VisibilityMasked @@ -96,24 +128,26 @@ const ( UnresolvedMissing = model.UnresolvedReasonMissing UnresolvedInvalid = model.UnresolvedReasonInvalid + + GeneratedDotenvSpecHeaderPrefix = requirements.GeneratedDotenvSpecHeaderPrefix ) type Store struct { runtime *graph.Runtime types registry.TypeProvider state model.EffectiveState - operations []store.OperationRecord + operations []state.OperationRecord clock model.Clock } type StoreOption func(*config) error type config struct { - envs []store.SourceBytes - specs []store.SourceBytes + envs []state.SourceBytes + specs []state.SourceBytes configs []configInputSource - contracts []store.EnvContract - envelope *store.StateEnvelope + contracts []state.EnvContract + envelope *state.StateEnvelope types registry.TypeProvider clock model.Clock } @@ -139,6 +173,101 @@ type ResolveInput struct { Chain ChainConfig } +type SnapshotInput struct { + Load LoadInput + Policy SnapshotPolicy + Filter SnapshotFilter +} + +type SnapshotFilter struct { + All bool + Limit int +} + +type SnapshotOutput struct { + Envs []SnapshotEnv + Diagnostics []Diagnostic +} + +type SourceInput struct { + Load LoadInput + Policy DotenvPolicy +} + +type SourceOutput struct { + Envs []string +} + +type GetInput struct { + Load LoadInput + Key string + Policy GetPolicy +} + +type GetOutput = GetResult + +type SensitiveKeysInput struct { + Load LoadInput +} + +type SensitiveKeysOutput struct { + Keys []string +} + +type DotenvSpecInput struct { + Load LoadInput +} + +type DotenvSpecOutput struct { + Rendered string +} + +type TypeInput struct { + Load LoadInput + Policy TypePolicy +} + +type TypeOutput = TypeResult + +type ProjectSpecInput struct { + Load LoadInput +} + +type ProjectSpecOutput struct { + Rendered string +} + +type UpdateInput struct { + Source Source + Dotenv []DotenvVariable + Delete []string +} + +type CheckInput struct { + Load LoadInput +} + +type CheckOutput struct { + OK bool + Diagnostics []Diagnostic + Checked int +} + +type GraphOperation struct { + Name string + Document string + Variables map[string]interface{} +} + +type GraphQLRequest struct { + Document string + Variables map[string]interface{} +} + +type GraphQLResult struct { + Data json.RawMessage +} + func ContextWithExecutionInfo(ctx context.Context, info ExecutionInfo) context.Context { if ctx == nil { ctx = context.Background() @@ -161,7 +290,7 @@ func NewStore(opts ...StoreOption) (*Store, error) { return nil, err } } - load, err := store.LoadInputFromSourceBytes(cfg.envs, cfg.specs) + load, err := state.LoadInputFromSourceBytes(cfg.envs, cfg.specs) if err != nil { return nil, err } @@ -187,8 +316,8 @@ func NewStore(opts ...StoreOption) (*Store, error) { runtime: runtime, types: cfg.types, clock: clock, - operations: []store.OperationRecord{ - {Kind: store.OperationRecordLoad, Timestamp: loadTimestamp, Load: load}, + operations: []state.OperationRecord{ + {Kind: state.OperationRecordLoad, Timestamp: loadTimestamp, Load: load}, }, } if err := s.materialize(context.Background()); err != nil { @@ -203,7 +332,7 @@ func WithDotenv(source string, r io.Reader) StoreOption { if err != nil { return err } - cfg.envs = append(cfg.envs, store.SourceBytes{Name: source, Raw: raw}) + cfg.envs = append(cfg.envs, state.SourceBytes{Name: source, Raw: raw}) return nil } } @@ -214,7 +343,7 @@ func WithEnvSpec(source string, r io.Reader) StoreOption { if err != nil { return err } - cfg.specs = append(cfg.specs, store.SourceBytes{Name: source, Raw: raw}) + cfg.specs = append(cfg.specs, state.SourceBytes{Name: source, Raw: raw}) return nil } } @@ -240,6 +369,20 @@ func WithConfigSource(source string, input ConfigInput) StoreOption { } } +func WithConfigFile(path string) StoreOption { + return func(cfg *config) error { + input, err := requirements.ReadConfigFile(path) + if err != nil { + return err + } + cfg.configs = append(cfg.configs, configInputSource{ + source: model.Source{Name: path, Kind: "owl-config"}, + input: input, + }) + return nil + } +} + func WithEnvContract(contract EnvContract) StoreOption { return func(cfg *config) error { cfg.contracts = append(cfg.contracts, contract) @@ -275,43 +418,247 @@ func withClock(clock model.Clock) StoreOption { } } -func (s *Store) Snapshot(policy SnapshotPolicy) ([]SnapshotItem, error) { - return store.NewState(s.state, s.types).Snapshot(policy) +func (s *Store) Snapshot(ctx context.Context, input SnapshotInput) (SnapshotOutput, error) { + if ctx == nil { + ctx = context.Background() + } + load, err := s.loadInputForOperation(ctx, input.Load) + if err != nil { + return SnapshotOutput{}, err + } + items, err := s.runtime.Snapshot(ctx, load, input.Policy) + if err != nil { + return SnapshotOutput{}, err + } + return SnapshotOutput{Envs: snapshotEnvsForInput(items, input)}, nil } -func (s *Store) Dotenv(policy DotenvPolicy) ([]string, error) { - return store.NewState(s.state, s.types).Dotenv(policy) +func (s *Store) BuildSnapshotOperation(ctx context.Context, input SnapshotInput) (GraphOperation, error) { + if ctx == nil { + ctx = context.Background() + } + load, err := s.loadInputForOperation(ctx, input.Load) + if err != nil { + return GraphOperation{}, err + } + return graphOperation(graph.SnapshotOperation(load, input.Policy)), nil +} + +func snapshotEnvsForInput(items []SnapshotItem, input SnapshotInput) []SnapshotEnv { + envs := make([]SnapshotEnv, 0, len(items)) + limit := input.Filter.Limit + for i, item := range items { + if limit > 0 && !input.Filter.All && i >= limit { + break + } + envs = append(envs, item) + } + return envs } -func (s *Store) DotenvSpec() (string, error) { - if len(s.operations) == 0 || s.operations[0].Kind != store.OperationRecordLoad { - return requirements.RenderDotenvSpec(nil, s.types) +func (s *Store) Source(ctx context.Context, input SourceInput) (SourceOutput, error) { + if ctx == nil { + ctx = context.Background() + } + load, err := s.loadInputForOperation(ctx, input.Load) + if err != nil { + return SourceOutput{}, err } - return requirements.RenderDotenvSpec(s.operations[0].Load.Contracts, s.types) + envs, err := s.runtime.Dotenv(ctx, load, input.Policy) + if err != nil { + return SourceOutput{}, err + } + return SourceOutput{Envs: envs}, nil } -func (s *Store) Type(policy TypePolicy) (TypeResult, error) { - return store.NewState(s.state, s.types).Type(policy) +func (s *Store) BuildSourceOperation(ctx context.Context, input SourceInput) (GraphOperation, error) { + if ctx == nil { + ctx = context.Background() + } + load, err := s.loadInputForOperation(ctx, input.Load) + if err != nil { + return GraphOperation{}, err + } + return graphOperation(graph.DotenvOperation(load, input.Policy)), nil } -func (s *Store) Get(key string, policy GetPolicy) (GetResult, bool, error) { - return store.NewState(s.state, s.types).Get(key, policy) +func (s *Store) DotenvSpec(ctx context.Context, input DotenvSpecInput) (DotenvSpecOutput, error) { + if ctx == nil { + ctx = context.Background() + } + load, err := s.loadInputForOperation(ctx, input.Load) + if err != nil { + return DotenvSpecOutput{}, err + } + rendered, err := s.runtime.DotenvSpec(ctx, load) + if err != nil { + return DotenvSpecOutput{}, err + } + return DotenvSpecOutput{Rendered: rendered}, nil } -func (s *Store) SensitiveKeys() ([]string, error) { - return store.NewState(s.state, s.types).SensitiveKeys() +func (s *Store) BuildDotenvSpecOperation(ctx context.Context, input DotenvSpecInput) (GraphOperation, error) { + if ctx == nil { + ctx = context.Background() + } + load, err := s.loadInputForOperation(ctx, input.Load) + if err != nil { + return GraphOperation{}, err + } + return graphOperation(graph.DotenvSpecOperation(load)), nil } -func (s *Store) Check() CheckResult { - return store.NewState(s.state, s.types).Check() +func (s *Store) ProjectSpec(ctx context.Context, input ProjectSpecInput) (ProjectSpecOutput, error) { + if ctx == nil { + ctx = context.Background() + } + load, err := s.loadInputForOperation(ctx, input.Load) + if err != nil { + return ProjectSpecOutput{}, err + } + rendered, err := s.runtime.ProjectSpec(ctx, load) + if err != nil { + return ProjectSpecOutput{}, err + } + return ProjectSpecOutput{Rendered: rendered}, nil +} + +func (s *Store) BuildProjectSpecOperation(ctx context.Context, input ProjectSpecInput) (GraphOperation, error) { + if ctx == nil { + ctx = context.Background() + } + load, err := s.loadInputForOperation(ctx, input.Load) + if err != nil { + return GraphOperation{}, err + } + return graphOperation(graph.ProjectSpecOperation(load)), nil +} + +func (s *Store) Type(ctx context.Context, input TypeInput) (TypeOutput, error) { + if ctx == nil { + ctx = context.Background() + } + load, err := s.loadInputForOperation(ctx, input.Load) + if err != nil { + return TypeOutput{}, err + } + return s.runtime.Type(ctx, load, input.Policy) +} + +func (s *Store) BuildTypeOperation(ctx context.Context, input TypeInput) (GraphOperation, error) { + if ctx == nil { + ctx = context.Background() + } + load, err := s.loadInputForOperation(ctx, input.Load) + if err != nil { + return GraphOperation{}, err + } + return graphOperation(graph.TypeOperation(load, input.Policy)), nil +} + +func (s *Store) Get(ctx context.Context, input GetInput) (GetOutput, bool, error) { + if ctx == nil { + ctx = context.Background() + } + load, err := s.loadInputForOperation(ctx, input.Load) + if err != nil { + return GetOutput{}, false, err + } + return s.runtime.Get(ctx, load, input.Key, input.Policy) +} + +func (s *Store) BuildGetOperation(ctx context.Context, input GetInput) (GraphOperation, error) { + if ctx == nil { + ctx = context.Background() + } + load, err := s.loadInputForOperation(ctx, input.Load) + if err != nil { + return GraphOperation{}, err + } + return graphOperation(graph.GetOperation(load, input.Key, input.Policy)), nil +} + +func (s *Store) SensitiveKeys(ctx context.Context, input SensitiveKeysInput) (SensitiveKeysOutput, error) { + if ctx == nil { + ctx = context.Background() + } + load, err := s.loadInputForOperation(ctx, input.Load) + if err != nil { + return SensitiveKeysOutput{}, err + } + keys, err := s.runtime.SensitiveKeys(ctx, load) + if err != nil { + return SensitiveKeysOutput{}, err + } + return SensitiveKeysOutput{Keys: keys}, nil +} + +func (s *Store) BuildSensitiveKeysOperation(ctx context.Context, input SensitiveKeysInput) (GraphOperation, error) { + if ctx == nil { + ctx = context.Background() + } + load, err := s.loadInputForOperation(ctx, input.Load) + if err != nil { + return GraphOperation{}, err + } + return graphOperation(graph.SensitiveKeysOperation(load)), nil +} + +func (s *Store) Check(ctx context.Context, input CheckInput) (CheckOutput, error) { + if ctx == nil { + ctx = context.Background() + } + load, err := s.loadInputForOperation(ctx, input.Load) + if err != nil { + return CheckOutput{}, err + } + check, err := s.runtime.Check(ctx, load) + if err != nil { + return CheckOutput{}, err + } + return CheckOutput{ + OK: check.OK, + Diagnostics: check.Diagnostics, + Checked: check.Checked, + }, nil +} + +func (s *Store) BuildCheckOperation(ctx context.Context, input CheckInput) (GraphOperation, error) { + if ctx == nil { + ctx = context.Background() + } + load, err := s.loadInputForOperation(ctx, input.Load) + if err != nil { + return GraphOperation{}, err + } + return graphOperation(graph.CheckOperation(load)), nil +} + +func (s *Store) ExecuteGraphQL(ctx context.Context, req GraphQLRequest) (GraphQLResult, error) { + if ctx == nil { + ctx = context.Background() + } + result, err := s.runtime.Execute(ctx, req.Document, req.Variables) + if err != nil { + return GraphQLResult{}, err + } + return GraphQLResult{Data: result.Data}, nil } func (s *Store) ResolverAttempts() []ResolverAttempt { - return append([]ResolverAttempt{}, s.state.ResolverAttempts...) + state, err := s.resolverState(context.Background()) + if err != nil { + return nil + } + return append([]ResolverAttempt{}, state.ResolverAttempts...) } func (s *Store) UnresolvedFrontier() UnresolvedFrontier { - return UnresolvedFrontier{Needs: append([]UnresolvedNeed{}, s.state.UnresolvedFrontier.Needs...)} + state, err := s.resolverState(context.Background()) + if err != nil { + return UnresolvedFrontier{} + } + return UnresolvedFrontier{Needs: append([]UnresolvedNeed{}, state.UnresolvedFrontier.Needs...)} } func (s *Store) Resolve(ctx context.Context, input ResolveInput) (ResolveResult, error) { @@ -327,8 +674,12 @@ func (s *Store) Resolve(ctx context.Context, input ResolveInput) (ResolveResult, NewAttemptID: publicAttemptIDGenerator(len(s.operations)), Clock: s.clock, } + state, err := s.resolverState(ctx) + if err != nil { + return ResolveResult{}, err + } result, err := runner.Resolve(ctx, resolver.RunRequest{ - State: s.state, + State: state, Policy: input.Policy, Chain: input.Chain, }) @@ -345,8 +696,12 @@ func (s *Store) ApplyPromptAnswers(ctx context.Context, answers []PromptAnswer) if ctx == nil { ctx = context.Background() } + effective, err := s.resolverState(ctx) + if err != nil { + return ResolveResult{}, err + } timestamp := s.clock() - needs := needsByID(s.state.UnresolvedFrontier.Needs) + needs := needsByID(effective.UnresolvedFrontier.Needs) newAttemptID := publicAttemptIDGenerator(len(s.operations)) var result ResolveResult for _, answer := range answers { @@ -363,8 +718,8 @@ func (s *Store) ApplyPromptAnswers(ctx context.Context, answers []PromptAnswer) attempt.Outcome = ResolverInvalidResult attempt.Message = "interactive answer references an unknown unresolved need" result.Attempts = append(result.Attempts, attempt) - s.operations = append(s.operations, store.OperationRecord{ - Kind: store.OperationRecordResolverAttempt, + s.operations = append(s.operations, state.OperationRecord{ + Kind: state.OperationRecordResolverAttempt, Timestamp: timestamp, ResolverAttempt: attempt, }) @@ -388,13 +743,13 @@ func (s *Store) ApplyPromptAnswers(ctx context.Context, answers []PromptAnswer) result.Attempts = append(result.Attempts, attempt) result.Proposals = append(result.Proposals, proposal) s.operations = append(s.operations, - store.OperationRecord{ - Kind: store.OperationRecordResolverAttempt, + state.OperationRecord{ + Kind: state.OperationRecordResolverAttempt, Timestamp: timestamp, ResolverAttempt: attempt, }, - store.OperationRecord{ - Kind: store.OperationRecordApplyResolverProposal, + state.OperationRecord{ + Kind: state.OperationRecordApplyResolverProposal, Timestamp: timestamp, ResolverProposal: proposal, }, @@ -406,46 +761,96 @@ func (s *Store) ApplyPromptAnswers(ctx context.Context, answers []PromptAnswer) return result, nil } -func (s *Store) LoadDotenv(source Source, vars []DotenvVariable) error { - return s.applyDotenv(source, vars, nil) +func (s *Store) resolverState(ctx context.Context) (model.EffectiveState, error) { + envelope, err := s.StateEnvelope(ctx) + if err != nil { + return model.EffectiveState{}, err + } + return envelope.State, nil } -func (s *Store) LoadDotenvLines(source string, envs ...string) error { - raw := strings.Join(envs, "\n") - if raw != "" { - raw += "\n" - } - input, err := store.LoadInputFromSourceBytes([]store.SourceBytes{{Name: source, Raw: []byte(raw)}}, nil) - if err != nil { - return err +func (s *Store) ApplyUpdate(ctx context.Context, input UpdateInput) error { + if ctx == nil { + ctx = context.Background() } - return s.LoadDotenv(input.DotenvSource, input.Dotenv) + return s.applyUpdateWithContext(ctx, input) } -func (s *Store) Update(ctx context.Context, newOrUpdated, deleted []string) error { +func (s *Store) BuildUpdateOperation(ctx context.Context, input UpdateInput) (GraphOperation, error) { if ctx == nil { ctx = context.Background() } - raw := strings.Join(newOrUpdated, "\n") - if raw != "" { - raw += "\n" + records := append([]state.OperationRecord{}, s.operations...) + timestamp := s.clock() + source := input.Source + if source == (Source{}) { + source = sourceFromContext(ctx, Source{Name: "[update]", Kind: "dotenv"}) } - input, err := store.LoadInputFromSourceBytes([]store.SourceBytes{{Name: "[update]", Raw: []byte(raw)}}, nil) - if err != nil { - return err + if len(input.Dotenv) > 0 { + records = append(records, state.OperationRecord{ + Kind: state.OperationRecordUpdate, + Timestamp: timestamp, + Update: state.UpdateOperation{ + Source: source, + Dotenv: append([]DotenvVariable{}, input.Dotenv...), + Timestamp: timestamp, + }, + }) + } + if len(input.Delete) > 0 { + records = append(records, state.OperationRecord{ + Kind: state.OperationRecordDelete, + Timestamp: timestamp, + Delete: state.DeleteOperation{ + Keys: append([]string{}, input.Delete...), + Source: source, + Timestamp: timestamp, + }, + }) } - return s.applyDotenvWithContext(ctx, sourceFromContext(ctx, input.DotenvSource), input.Dotenv, deleted) + return stateEnvelopeOperation(records) } -func (s *Store) Delete(ctx context.Context, keys ...string) error { +func (s *Store) StateEnvelope(ctx context.Context) (StateEnvelope, error) { if ctx == nil { ctx = context.Background() } - return s.applyDotenvWithContext(ctx, sourceFromContext(ctx, Source{}), nil, keys) + return s.runtime.StateEnvelopeForOperations(ctx, s.operations) } -func (s *Store) StateEnvelope(ctx context.Context) (StateEnvelope, error) { - return store.NewState(s.state, s.types).StateEnvelope(), nil +func (s *Store) loadInputForOperation(ctx context.Context, input LoadInput) (LoadInput, error) { + if !isZeroLoadInput(input) { + return input, nil + } + envelope, err := s.StateEnvelope(ctx) + if err != nil { + return LoadInput{}, err + } + return LoadInput{Envelope: &envelope}, nil +} + +func isZeroLoadInput(input LoadInput) bool { + return input.DotenvSource == (Source{}) && + len(input.Dotenv) == 0 && + len(input.Contracts) == 0 && + input.Envelope == nil && + input.Timestamp.IsZero() +} + +func graphOperation(op graph.Operation) GraphOperation { + return GraphOperation{ + Name: op.Name, + Document: op.Document, + Variables: op.Variables, + } +} + +func stateEnvelopeOperation(records []state.OperationRecord) (GraphOperation, error) { + op, err := graph.StateEnvelopeOperation(records) + if err != nil { + return GraphOperation{}, err + } + return graphOperation(op), nil } func (s *Store) GraphQLSchema() (string, error) { @@ -461,39 +866,39 @@ func GraphQLSchema() (string, error) { } func Diagnostics(err error) []Diagnostic { - return store.Diagnostics(err) + return state.Diagnostics(err) } -func (s *Store) applyDotenv(source Source, vars []DotenvVariable, deleted []string) error { - return s.applyDotenvWithContext(context.Background(), source, vars, deleted) -} - -func (s *Store) applyDotenvWithContext(ctx context.Context, source Source, vars []DotenvVariable, deleted []string) error { +func (s *Store) applyUpdateWithContext(ctx context.Context, input UpdateInput) error { if ctx == nil { ctx = context.Background() } - if len(vars) == 0 && len(deleted) == 0 { + if len(input.Dotenv) == 0 && len(input.Delete) == 0 { return nil } - if len(vars) > 0 { + source := input.Source + if source == (Source{}) { + source = sourceFromContext(ctx, Source{Name: "[update]", Kind: "dotenv"}) + } + if len(input.Dotenv) > 0 { timestamp := s.clock() - s.operations = append(s.operations, store.OperationRecord{ - Kind: store.OperationRecordUpdate, + s.operations = append(s.operations, state.OperationRecord{ + Kind: state.OperationRecordUpdate, Timestamp: timestamp, - Update: store.UpdateOperation{ + Update: state.UpdateOperation{ Source: source, - Dotenv: vars, + Dotenv: append([]DotenvVariable{}, input.Dotenv...), Timestamp: timestamp, }, }) } - if len(deleted) > 0 { + if len(input.Delete) > 0 { timestamp := s.clock() - s.operations = append(s.operations, store.OperationRecord{ - Kind: store.OperationRecordDelete, + s.operations = append(s.operations, state.OperationRecord{ + Kind: state.OperationRecordDelete, Timestamp: timestamp, - Delete: store.DeleteOperation{ - Keys: append([]string{}, deleted...), + Delete: state.DeleteOperation{ + Keys: append([]string{}, input.Delete...), Source: source, Timestamp: timestamp, }, @@ -536,15 +941,15 @@ func sourceFromContext(ctx context.Context, fallback Source) Source { func (s *Store) recordResolverResult(ctx context.Context, result ResolveResult) error { timestamp := s.clock() for _, attempt := range result.Attempts { - s.operations = append(s.operations, store.OperationRecord{ - Kind: store.OperationRecordResolverAttempt, + s.operations = append(s.operations, state.OperationRecord{ + Kind: state.OperationRecordResolverAttempt, Timestamp: firstTime(attempt.FinishedAt, timestamp), ResolverAttempt: attempt, }) } for _, proposal := range result.Proposals { - s.operations = append(s.operations, store.OperationRecord{ - Kind: store.OperationRecordApplyResolverProposal, + s.operations = append(s.operations, state.OperationRecord{ + Kind: state.OperationRecordApplyResolverProposal, Timestamp: timestamp, ResolverProposal: proposal, }) diff --git a/pkg/owl/api_internal_test.go b/pkg/owl/api_internal_test.go index 7b61a66..1034718 100644 --- a/pkg/owl/api_internal_test.go +++ b/pkg/owl/api_internal_test.go @@ -10,6 +10,31 @@ import ( "github.com/stretchr/testify/require" ) +func snapshotItems(t *testing.T, store *Store, policy SnapshotPolicy) []SnapshotItem { + t.Helper() + output, err := store.Snapshot(context.Background(), SnapshotInput{ + Policy: policy, + Filter: SnapshotFilter{All: true}, + }) + require.NoError(t, err) + return output.Envs +} + +func applyUpdateLines(ctx context.Context, t *testing.T, store *Store, source Source, lines []string, deleted []string) { + t.Helper() + var vars []DotenvVariable + for _, line := range lines { + key, value, ok := strings.Cut(line, "=") + require.True(t, ok, "update line must be KEY=value") + vars = append(vars, DotenvVariable{Key: key, Value: value, Source: source}) + } + require.NoError(t, store.ApplyUpdate(ctx, UpdateInput{ + Source: source, + Dotenv: vars, + Delete: deleted, + })) +} + func TestPublicAPIUpdateTimestampsOnlyChangedItems(t *testing.T) { t.Parallel() @@ -25,22 +50,19 @@ func TestPublicAPIUpdateTimestampsOnlyChangedItems(t *testing.T) { ) require.NoError(t, err) - initial, err := store.Snapshot(SnapshotPolicy{Reveal: true}) - require.NoError(t, err) + initial := snapshotItems(t, store, SnapshotPolicy{Reveal: true}) initialByName := snapshotItemsByName(initial) assert.Equal(t, timestamps.At(0), initialByName["API_URL"].UpdatedAt) assert.Equal(t, timestamps.At(0), initialByName["TOKEN"].UpdatedAt) - require.NoError(t, store.Update(context.Background(), []string{"API_URL=https://two.example.com"}, nil)) - afterAPIURLUpdate, err := store.Snapshot(SnapshotPolicy{Reveal: true}) - require.NoError(t, err) + applyUpdateLines(context.Background(), t, store, Source{Name: "[update]", Kind: "dotenv"}, []string{"API_URL=https://two.example.com"}, nil) + afterAPIURLUpdate := snapshotItems(t, store, SnapshotPolicy{Reveal: true}) afterAPIURLUpdateByName := snapshotItemsByName(afterAPIURLUpdate) assert.Equal(t, timestamps.At(1), afterAPIURLUpdateByName["API_URL"].UpdatedAt) assert.Equal(t, timestamps.At(0), afterAPIURLUpdateByName["TOKEN"].UpdatedAt) - require.NoError(t, store.Update(context.Background(), []string{"TOKEN=two"}, nil)) - afterTokenUpdate, err := store.Snapshot(SnapshotPolicy{Reveal: true}) - require.NoError(t, err) + applyUpdateLines(context.Background(), t, store, Source{Name: "[update]", Kind: "dotenv"}, []string{"TOKEN=two"}, nil) + afterTokenUpdate := snapshotItems(t, store, SnapshotPolicy{Reveal: true}) afterTokenUpdateByName := snapshotItemsByName(afterTokenUpdate) assert.Equal(t, timestamps.At(1), afterTokenUpdateByName["API_URL"].UpdatedAt) assert.Equal(t, timestamps.At(2), afterTokenUpdateByName["TOKEN"].UpdatedAt) @@ -59,17 +81,15 @@ func TestPublicAPIUpdateClearsResolvedRequiredDiagnostics(t *testing.T) { ) require.NoError(t, err) - initial, err := store.Snapshot(SnapshotPolicy{Reveal: true}) - require.NoError(t, err) + initial := snapshotItems(t, store, SnapshotPolicy{Reveal: true}) initialByName := snapshotItemsByName(initial) require.Contains(t, initialByName, "TOKEN") assert.Contains(t, diagnosticCodes(initialByName["TOKEN"].Diagnostics), "dotenv.unresolved-required") ctx := ContextWithExecutionInfo(context.Background(), ExecutionInfo{ExecContext: "direnv"}) - require.NoError(t, store.Update(ctx, []string{"TOKEN=secret"}, nil)) + applyUpdateLines(ctx, t, store, Source{Name: "[direnv]", Kind: "direnv"}, []string{"TOKEN=secret"}, nil) - updated, err := store.Snapshot(SnapshotPolicy{Reveal: true}) - require.NoError(t, err) + updated := snapshotItems(t, store, SnapshotPolicy{Reveal: true}) updatedByName := snapshotItemsByName(updated) assert.Equal(t, "secret", updatedByName["TOKEN"].Value) assert.Equal(t, "[direnv]", updatedByName["TOKEN"].Source.Name) @@ -91,7 +111,7 @@ func TestPublicAPIStateEnvelopePreservesOperationTimestamps(t *testing.T) { withClock(timestamps.Next), ) require.NoError(t, err) - require.NoError(t, store.Update(context.Background(), []string{"API_URL=https://two.example.com"}, nil)) + applyUpdateLines(context.Background(), t, store, Source{Name: "[update]", Kind: "dotenv"}, []string{"API_URL=https://two.example.com"}, nil) envelope, err := store.StateEnvelope(context.Background()) require.NoError(t, err) @@ -109,9 +129,8 @@ func TestPublicAPIStateEnvelopePreservesOperationTimestamps(t *testing.T) { assert.Contains(t, operationTimestamps(roundTrippedEnvelope.Provenance.Operations), timestamps.At(0)) assert.Contains(t, operationTimestamps(roundTrippedEnvelope.Provenance.Operations), timestamps.At(1)) - require.NoError(t, roundTripped.Update(context.Background(), []string{"API_URL=https://three.example.com"}, nil)) - snapshot, err := roundTripped.Snapshot(SnapshotPolicy{Reveal: true}) - require.NoError(t, err) + applyUpdateLines(context.Background(), t, roundTripped, Source{Name: "[update]", Kind: "dotenv"}, []string{"API_URL=https://three.example.com"}, nil) + snapshot := snapshotItems(t, roundTripped, SnapshotPolicy{Reveal: true}) assert.Equal(t, timestamps.At(2), snapshotItemsByName(snapshot)["API_URL"].UpdatedAt) } diff --git a/pkg/owl/api_test.go b/pkg/owl/api_test.go index a20b70c..f586e93 100644 --- a/pkg/owl/api_test.go +++ b/pkg/owl/api_test.go @@ -3,6 +3,8 @@ package owl_test import ( "context" "errors" + "os" + "path/filepath" "strings" "testing" "time" @@ -22,26 +24,24 @@ func TestV2PublicAPI(t *testing.T) { ) require.NoError(t, err) - snapshot, err := store.Snapshot(owl.SnapshotPolicy{}) - require.NoError(t, err) + snapshot := snapshotItems(t, store, owl.SnapshotPolicy{}) assert.Equal(t, "[masked]", snapshotByName(snapshot)["API_KEY"].Value) - envs, err := store.Dotenv(owl.DotenvPolicy{Insecure: true}) - require.NoError(t, err) + envs := dotenvLines(t, store, owl.DotenvPolicy{Insecure: true}) assert.Equal(t, []string{ "API_KEY=secret", "API_URL=https://api.example.com", "REDIS_PASSWORD=hunter2", }, envs) - got, ok, err := store.Get("API_KEY", owl.GetPolicy{}) + got, ok, err := store.Get(context.Background(), owl.GetInput{Key: "API_KEY"}) require.NoError(t, err) require.True(t, ok) assert.Equal(t, "[masked]", got.Value) - keys, err := store.SensitiveKeys() + keys, err := store.SensitiveKeys(context.Background(), owl.SensitiveKeysInput{}) require.NoError(t, err) - assert.Equal(t, []string{"API_KEY"}, keys) + assert.Equal(t, []string{"API_KEY"}, keys.Keys) envelope, err := store.StateEnvelope(context.Background()) require.NoError(t, err) @@ -49,19 +49,234 @@ func TestV2PublicAPI(t *testing.T) { next, err := owl.NewStore(owl.WithStateEnvelope(envelope)) require.NoError(t, err) - require.NoError(t, next.LoadDotenvLines("[override]", "API_URL=https://next.example.com")) + applyUpdateLines(context.Background(), t, next, owl.Source{Name: "[override]", Kind: "dotenv"}, []string{"API_URL=https://next.example.com"}, nil) - got, ok, err = next.Get("API_URL", owl.GetPolicy{Reveal: true}) + got, ok, err = next.Get(context.Background(), owl.GetInput{Key: "API_URL", Policy: owl.GetPolicy{Reveal: true}}) require.NoError(t, err) require.True(t, ok) assert.Equal(t, "https://next.example.com", got.Value) - require.NoError(t, next.Delete(context.Background(), "API_KEY")) - _, ok, err = next.Get("API_KEY", owl.GetPolicy{Reveal: true}) + applyUpdateLines(context.Background(), t, next, owl.Source{Name: "[update]", Kind: "dotenv"}, nil, []string{"API_KEY"}) + _, ok, err = next.Get(context.Background(), owl.GetInput{Key: "API_KEY", Policy: owl.GetPolicy{Reveal: true}}) require.NoError(t, err) assert.False(t, ok) } +func snapshotItems(t *testing.T, store *owl.Store, policy owl.SnapshotPolicy) []owl.SnapshotItem { + t.Helper() + output, err := store.Snapshot(context.Background(), owl.SnapshotInput{ + Policy: policy, + Filter: owl.SnapshotFilter{All: true}, + }) + require.NoError(t, err) + return output.Envs +} + +func dotenvLines(t *testing.T, store *owl.Store, policy owl.DotenvPolicy) []string { + t.Helper() + output, err := store.Source(context.Background(), owl.SourceInput{Policy: policy}) + require.NoError(t, err) + return output.Envs +} + +func checkStore(t *testing.T, store *owl.Store) owl.CheckOutput { + t.Helper() + output, err := store.Check(context.Background(), owl.CheckInput{}) + require.NoError(t, err) + return output +} + +func applyUpdateLines(ctx context.Context, t *testing.T, store *owl.Store, source owl.Source, lines []string, deleted []string) { + t.Helper() + var vars []owl.DotenvVariable + for _, line := range lines { + key, value, ok := strings.Cut(line, "=") + require.True(t, ok, "update line must be KEY=value") + vars = append(vars, owl.DotenvVariable{Key: key, Value: value, Source: source}) + } + require.NoError(t, store.ApplyUpdate(ctx, owl.UpdateInput{ + Source: source, + Dotenv: vars, + Delete: deleted, + })) +} + +func copyCUECatalog(t *testing.T) string { + t.Helper() + root, err := filepath.Abs("../..") + require.NoError(t, err) + destination := t.TempDir() + for _, name := range []string{"cue.mod", "schema", "types"} { + require.NoError(t, os.CopyFS(filepath.Join(destination, name), os.DirFS(filepath.Join(root, name)))) + } + return destination +} + +func TestPublicAPIOperationsUseGraphShapedLoadInput(t *testing.T) { + t.Parallel() + + store, err := owl.NewStore( + owl.WithDotenv(".env", strings.NewReader("API_URL=https://api.example.com\nAPI_KEY=secret\n")), + owl.WithEnvSpec(".env.spec", strings.NewReader("API_URL=\"API URL\" # Plain!\nAPI_KEY=\"API key\" # Secret!\n")), + ) + require.NoError(t, err) + + snapshot, err := store.Snapshot(context.Background(), owl.SnapshotInput{ + Policy: owl.SnapshotPolicy{Reveal: true}, + Filter: owl.SnapshotFilter{Limit: 1}, + }) + require.NoError(t, err) + require.Len(t, snapshot.Envs, 1) + assert.Equal(t, "API_URL", snapshot.Envs[0].Name) + assert.Equal(t, owl.Source{Name: ".env", Kind: "dotenv"}, snapshot.Envs[0].Source) + + source, err := store.Source(context.Background(), owl.SourceInput{ + Policy: owl.DotenvPolicy{Insecure: true}, + }) + require.NoError(t, err) + assert.Equal(t, []string{ + "API_KEY=secret", + "API_URL=https://api.example.com", + }, source.Envs) + + check, err := store.Check(context.Background(), owl.CheckInput{}) + require.NoError(t, err) + assert.True(t, check.OK) + assert.Equal(t, 2, check.Checked) + + for _, op := range []owl.GraphOperation{ + mustBuildSnapshotOperation(t, store), + mustBuildSourceOperation(t, store), + mustBuildGetOperation(t, store), + mustBuildSensitiveKeysOperation(t, store), + mustBuildDotenvSpecOperation(t, store), + mustBuildProjectSpecOperation(t, store), + mustBuildTypeOperation(t, store), + mustBuildCheckOperation(t, store), + mustBuildUpdateOperation(t, store), + } { + if input, ok := op.Variables["input"].(map[string]interface{}); ok { + assert.Contains(t, input, "envelope") + } + assert.NotEmpty(t, op.Variables) + } +} + +func TestTypeProviderFromCatalogInput(t *testing.T) { + t.Parallel() + + builtin, err := owl.TypeProviderFromCatalogInput(owl.TypeCatalogInput{}) + require.NoError(t, err) + _, ok, err := builtin.ResolveTypeRef("github.com/runmedev/owl/types/universe/redis") + require.NoError(t, err) + assert.True(t, ok) + + _, err = owl.TypeProviderFromCatalogInput(owl.TypeCatalogInput{Root: filepath.Join(t.TempDir(), "missing")}) + require.Error(t, err) + + root := copyCUECatalog(t) + redisType := filepath.Join(root, "types/universe/redis/type.cue") + raw, err := os.ReadFile(redisType) + require.NoError(t, err) + raw = []byte(strings.Replace(string(raw), "(uint & >=1 & <=65535)", "6380", 1)) + require.NoError(t, os.WriteFile(redisType, raw, 0o600)) + + types, err := owl.TypeProviderFromCatalogInput(owl.TypeCatalogInput{Root: root}) + require.NoError(t, err) + validator, ok := types.(interface { + ValidateFieldValue(owl.FieldRef, string) error + }) + require.True(t, ok) + ref := owl.FieldRef{TypeID: owl.TypeUniverseRedis, Instance: "queues", Field: "port"} + require.NoError(t, validator.ValidateFieldValue(ref, "6380")) + require.Error(t, validator.ValidateFieldValue(ref, "6379")) +} + +func mustBuildSnapshotOperation(t *testing.T, store *owl.Store) owl.GraphOperation { + t.Helper() + op, err := store.BuildSnapshotOperation(context.Background(), owl.SnapshotInput{}) + require.NoError(t, err) + assert.Equal(t, "OwlSnapshot", op.Name) + assert.Contains(t, op.Document, "$input: LoadInput!") + return op +} + +func mustBuildSourceOperation(t *testing.T, store *owl.Store) owl.GraphOperation { + t.Helper() + op, err := store.BuildSourceOperation(context.Background(), owl.SourceInput{}) + require.NoError(t, err) + assert.Equal(t, "OwlDotenv", op.Name) + assert.Contains(t, op.Document, "$input: LoadInput!") + return op +} + +func mustBuildGetOperation(t *testing.T, store *owl.Store) owl.GraphOperation { + t.Helper() + op, err := store.BuildGetOperation(context.Background(), owl.GetInput{Key: "API_KEY"}) + require.NoError(t, err) + assert.Equal(t, "OwlGet", op.Name) + assert.Contains(t, op.Document, "$input: LoadInput!") + return op +} + +func mustBuildSensitiveKeysOperation(t *testing.T, store *owl.Store) owl.GraphOperation { + t.Helper() + op, err := store.BuildSensitiveKeysOperation(context.Background(), owl.SensitiveKeysInput{}) + require.NoError(t, err) + assert.Equal(t, "OwlSensitiveKeys", op.Name) + assert.Contains(t, op.Document, "$input: LoadInput!") + return op +} + +func mustBuildDotenvSpecOperation(t *testing.T, store *owl.Store) owl.GraphOperation { + t.Helper() + op, err := store.BuildDotenvSpecOperation(context.Background(), owl.DotenvSpecInput{}) + require.NoError(t, err) + assert.Equal(t, "OwlDotenvSpec", op.Name) + assert.Contains(t, op.Document, "$input: LoadInput!") + return op +} + +func mustBuildProjectSpecOperation(t *testing.T, store *owl.Store) owl.GraphOperation { + t.Helper() + op, err := store.BuildProjectSpecOperation(context.Background(), owl.ProjectSpecInput{}) + require.NoError(t, err) + assert.Equal(t, "OwlProjectSpec", op.Name) + assert.Contains(t, op.Document, "$input: LoadInput!") + return op +} + +func mustBuildTypeOperation(t *testing.T, store *owl.Store) owl.GraphOperation { + t.Helper() + op, err := store.BuildTypeOperation(context.Background(), owl.TypeInput{Policy: owl.TypePolicy{All: true}}) + require.NoError(t, err) + assert.Equal(t, "OwlTypeSuggestions", op.Name) + assert.Contains(t, op.Document, "$input: LoadInput!") + return op +} + +func mustBuildCheckOperation(t *testing.T, store *owl.Store) owl.GraphOperation { + t.Helper() + op, err := store.BuildCheckOperation(context.Background(), owl.CheckInput{}) + require.NoError(t, err) + assert.Equal(t, "OwlCheck", op.Name) + assert.Contains(t, op.Document, "$input: LoadInput!") + return op +} + +func mustBuildUpdateOperation(t *testing.T, store *owl.Store) owl.GraphOperation { + t.Helper() + op, err := store.BuildUpdateOperation(context.Background(), owl.UpdateInput{ + Dotenv: []owl.DotenvVariable{{Key: "API_URL", Value: "https://next.example.com"}}, + Delete: []string{"API_KEY"}, + }) + require.NoError(t, err) + assert.Equal(t, "OwlStateEnvelope", op.Name) + assert.Contains(t, op.Document, "update") + assert.Contains(t, op.Document, "delete") + return op +} + func TestPublicAPISnapshotOrderSurvivesStateEnvelopeRoundTrip(t *testing.T) { t.Parallel() @@ -77,8 +292,7 @@ func TestPublicAPISnapshotOrderSurvivesStateEnvelopeRoundTrip(t *testing.T) { roundTripped, err := owl.NewStore(owl.WithStateEnvelope(envelope)) require.NoError(t, err) - snapshot, err := roundTripped.Snapshot(owl.SnapshotPolicy{Reveal: true}) - require.NoError(t, err) + snapshot := snapshotItems(t, roundTripped, owl.SnapshotPolicy{Reveal: true}) assert.Equal(t, []string{"ZETA", "BETA", "APPLE", "OMEGA"}, snapshotNames(snapshot)) } @@ -91,8 +305,7 @@ func TestPublicAPIVisibilityAndExposure(t *testing.T) { ) require.NoError(t, err) - snapshot, err := store.Snapshot(owl.SnapshotPolicy{}) - require.NoError(t, err) + snapshot := snapshotItems(t, store, owl.SnapshotPolicy{}) byName := snapshotByName(snapshot) assert.Equal(t, "https://api.example.com", byName["API_URL"].Value) @@ -115,8 +328,7 @@ func TestPublicAPIVisibilityAndExposure(t *testing.T) { assert.Empty(t, byName["MISSING_TOKEN"].Source) assert.Equal(t, ".env.spec", byName["MISSING_TOKEN"].Origin.Name) - revealed, err := store.Snapshot(owl.SnapshotPolicy{Reveal: true}) - require.NoError(t, err) + revealed := snapshotItems(t, store, owl.SnapshotPolicy{Reveal: true}) revealedByName := snapshotByName(revealed) assert.Equal(t, "secret", revealedByName["API_KEY"].Value) assert.Equal(t, owl.VisibilityLiteral, revealedByName["API_KEY"].Visibility) @@ -133,8 +345,7 @@ func TestPublicAPIUndeclaredOpaqueKeysStayHidden(t *testing.T) { ) require.NoError(t, err) - snapshot, err := store.Snapshot(owl.SnapshotPolicy{}) - require.NoError(t, err) + snapshot := snapshotItems(t, store, owl.SnapshotPolicy{}) byName := snapshotByName(snapshot) for _, name := range []string{"OPENAI_API_KEY", "SOMETHING_TOKEN", "REDIS_PASSWORD"} { @@ -146,8 +357,7 @@ func TestPublicAPIUndeclaredOpaqueKeysStayHidden(t *testing.T) { assert.Equal(t, "[process]", byName[name].Source.Name) } - revealed, err := store.Snapshot(owl.SnapshotPolicy{Reveal: true}) - require.NoError(t, err) + revealed := snapshotItems(t, store, owl.SnapshotPolicy{Reveal: true}) revealedByName := snapshotByName(revealed) assert.Equal(t, "sk-example", revealedByName["OPENAI_API_KEY"].Value) assert.Equal(t, "token-value", revealedByName["SOMETHING_TOKEN"].Value) @@ -163,8 +373,7 @@ func TestPublicAPIObservedEmptyValuesArePresent(t *testing.T) { ) require.NoError(t, err) - snapshot, err := store.Snapshot(owl.SnapshotPolicy{}) - require.NoError(t, err) + snapshot := snapshotItems(t, store, owl.SnapshotPolicy{}) byName := snapshotByName(snapshot) assert.Equal(t, "[masked]", byName["RUNME_TEST_TOKEN"].Value) @@ -181,15 +390,14 @@ func TestPublicAPIObservedEmptyValuesArePresent(t *testing.T) { assert.Equal(t, "[process]", byName["EMPTY_OPAQUE"].Source.Name) assert.Equal(t, ".env.spec", byName["EMPTY_OPAQUE"].Origin.Name) - revealed, err := store.Snapshot(owl.SnapshotPolicy{Reveal: true}) - require.NoError(t, err) + revealed := snapshotItems(t, store, owl.SnapshotPolicy{Reveal: true}) revealedByName := snapshotByName(revealed) assert.Equal(t, "", revealedByName["RUNME_TEST_TOKEN"].Value) assert.Equal(t, owl.VisibilityLiteral, revealedByName["RUNME_TEST_TOKEN"].Visibility) assert.Equal(t, "", revealedByName["EMPTY_OPAQUE"].Value) assert.Equal(t, owl.VisibilityLiteral, revealedByName["EMPTY_OPAQUE"].Visibility) - check := store.Check() + check := checkStore(t, store) assert.False(t, check.OK) assert.Contains(t, diagnosticCodes(check.Diagnostics), "type.invalid-secret") assert.NotContains(t, diagnosticCodes(check.Diagnostics), "dotenv.unresolved-required") @@ -204,27 +412,27 @@ func TestPublicAPIGetRevealPolicy(t *testing.T) { ) require.NoError(t, err) - got, ok, err := store.Get("API_KEY", owl.GetPolicy{}) + got, ok, err := store.Get(context.Background(), owl.GetInput{Key: "API_KEY"}) require.NoError(t, err) require.True(t, ok) assert.Equal(t, "[masked]", got.Value) assert.Equal(t, owl.VisibilityMasked, got.Visibility) assert.Equal(t, owl.ExposureClear, got.Exposure) - got, ok, err = store.Get("API_KEY", owl.GetPolicy{Reveal: true}) + got, ok, err = store.Get(context.Background(), owl.GetInput{Key: "API_KEY", Policy: owl.GetPolicy{Reveal: true}}) require.NoError(t, err) require.True(t, ok) assert.Equal(t, "secret", got.Value) assert.Equal(t, owl.VisibilityLiteral, got.Visibility) - got, ok, err = store.Get("DATABASE_URL", owl.GetPolicy{}) + got, ok, err = store.Get(context.Background(), owl.GetInput{Key: "DATABASE_URL"}) require.NoError(t, err) require.True(t, ok) assert.Equal(t, "[hidden]", got.Value) assert.Equal(t, owl.VisibilityHidden, got.Visibility) assert.Equal(t, owl.ExposureOpaque, got.Exposure) - got, ok, err = store.Get("DATABASE_URL", owl.GetPolicy{Reveal: true}) + got, ok, err = store.Get(context.Background(), owl.GetInput{Key: "DATABASE_URL", Policy: owl.GetPolicy{Reveal: true}}) require.NoError(t, err) require.True(t, ok) assert.Equal(t, "postgres://example", got.Value) @@ -241,23 +449,21 @@ func TestPublicAPIDotenvSecureAndInsecure(t *testing.T) { ) require.NoError(t, err) - safe, err := store.Dotenv(owl.DotenvPolicy{}) - require.NoError(t, err) + safe := dotenvLines(t, store, owl.DotenvPolicy{}) assert.Equal(t, []string{ "API_KEY=[masked]", "API_URL=https://api.example.com", "DATABASE_URL=[hidden]", }, safe) - insecure, err := store.Dotenv(owl.DotenvPolicy{Insecure: true}) - require.NoError(t, err) + insecure := dotenvLines(t, store, owl.DotenvPolicy{Insecure: true}) assert.Equal(t, []string{ "API_KEY=secret", "API_URL=https://api.example.com", "DATABASE_URL=postgres://example", }, insecure) - check := store.Check() + check := checkStore(t, store) assert.False(t, check.OK) assert.Contains(t, diagnosticCodes(check.Diagnostics), "dotenv.unresolved-required") } @@ -278,21 +484,19 @@ func TestPublicAPIStateEnvelopeRoundTrip(t *testing.T) { roundTripped, err := owl.NewStore(owl.WithStateEnvelope(envelope)) require.NoError(t, err) - snapshot, err := store.Snapshot(owl.SnapshotPolicy{}) - require.NoError(t, err) - roundTrippedSnapshot, err := roundTripped.Snapshot(owl.SnapshotPolicy{}) - require.NoError(t, err) + snapshot := snapshotItems(t, store, owl.SnapshotPolicy{}) + roundTrippedSnapshot := snapshotItems(t, roundTripped, owl.SnapshotPolicy{}) assert.Equal(t, snapshotByName(snapshot)["API_KEY"].Visibility, snapshotByName(roundTrippedSnapshot)["API_KEY"].Visibility) assert.Equal(t, snapshotByName(snapshot)["DATABASE_URL"].Exposure, snapshotByName(roundTrippedSnapshot)["DATABASE_URL"].Exposure) - require.NoError(t, roundTripped.LoadDotenvLines("[override]", "API_URL=https://next.example.com")) - got, ok, err := roundTripped.Get("API_URL", owl.GetPolicy{Reveal: true}) + applyUpdateLines(context.Background(), t, roundTripped, owl.Source{Name: "[override]", Kind: "dotenv"}, []string{"API_URL=https://next.example.com"}, nil) + got, ok, err := roundTripped.Get(context.Background(), owl.GetInput{Key: "API_URL", Policy: owl.GetPolicy{Reveal: true}}) require.NoError(t, err) require.True(t, ok) assert.Equal(t, "https://next.example.com", got.Value) - require.NoError(t, roundTripped.Delete(context.Background(), "API_KEY")) - _, ok, err = roundTripped.Get("API_KEY", owl.GetPolicy{Reveal: true}) + applyUpdateLines(context.Background(), t, roundTripped, owl.Source{Name: "[update]", Kind: "dotenv"}, nil, []string{"API_KEY"}) + _, ok, err = roundTripped.Get(context.Background(), owl.GetInput{Key: "API_KEY", Policy: owl.GetPolicy{Reveal: true}}) require.NoError(t, err) assert.False(t, ok) } @@ -339,7 +543,7 @@ func TestPublicAPIStateEnvelopeRoundTripPreservesResolverAttempts(t *testing.T) assert.Equal(t, attempt.FinishedAt, gotAttempt.FinishedAt) assert.Empty(t, gotAttempt.Diagnostics) - got, ok, err := roundTripped.Get("API_URL", owl.GetPolicy{Reveal: true}) + got, ok, err := roundTripped.Get(context.Background(), owl.GetInput{Key: "API_URL", Policy: owl.GetPolicy{Reveal: true}}) require.NoError(t, err) require.True(t, ok) assert.Equal(t, "https://api.example.com", got.Value) @@ -383,15 +587,15 @@ func TestPublicAPIUpdatesMaterializeFromOperationLog(t *testing.T) { ) require.NoError(t, err) - require.NoError(t, store.Update(context.Background(), []string{"API_URL=https://one.example.com"}, nil)) - require.NoError(t, store.Update(context.Background(), []string{"API_URL=https://two.example.com"}, nil)) - got, ok, err := store.Get("API_URL", owl.GetPolicy{Reveal: true}) + applyUpdateLines(context.Background(), t, store, owl.Source{Name: "[update]", Kind: "dotenv"}, []string{"API_URL=https://one.example.com"}, nil) + applyUpdateLines(context.Background(), t, store, owl.Source{Name: "[update]", Kind: "dotenv"}, []string{"API_URL=https://two.example.com"}, nil) + got, ok, err := store.Get(context.Background(), owl.GetInput{Key: "API_URL", Policy: owl.GetPolicy{Reveal: true}}) require.NoError(t, err) require.True(t, ok) assert.Equal(t, "https://two.example.com", got.Value) - require.NoError(t, store.Delete(context.Background(), "API_KEY")) - _, ok, err = store.Get("API_KEY", owl.GetPolicy{Reveal: true}) + applyUpdateLines(context.Background(), t, store, owl.Source{Name: "[update]", Kind: "dotenv"}, nil, []string{"API_KEY"}) + _, ok, err = store.Get(context.Background(), owl.GetInput{Key: "API_KEY", Policy: owl.GetPolicy{Reveal: true}}) require.NoError(t, err) assert.False(t, ok) @@ -399,7 +603,7 @@ func TestPublicAPIUpdatesMaterializeFromOperationLog(t *testing.T) { require.NoError(t, err) roundTripped, err := owl.NewStore(owl.WithStateEnvelope(envelope)) require.NoError(t, err) - got, ok, err = roundTripped.Get("API_URL", owl.GetPolicy{Reveal: true}) + got, ok, err = roundTripped.Get(context.Background(), owl.GetInput{Key: "API_URL", Policy: owl.GetPolicy{Reveal: true}}) require.NoError(t, err) require.True(t, ok) assert.Equal(t, "https://two.example.com", got.Value) @@ -419,13 +623,12 @@ func TestPublicAPIExecutionInfoSetsUpdateSource(t *testing.T) { KnownName: "cell-name", ExecContext: "direnv", }) - require.NoError(t, store.Update(ctx, []string{ + applyUpdateLines(ctx, t, store, owl.Source{}, []string{ "API_URL=https://next.example.com", "TOKEN=secret", - }, nil)) + }, nil) - snapshot, err := store.Snapshot(owl.SnapshotPolicy{Reveal: true}) - require.NoError(t, err) + snapshot := snapshotItems(t, store, owl.SnapshotPolicy{Reveal: true}) byName := snapshotByName(snapshot) assert.Equal(t, "[direnv]", byName["API_URL"].Source.Name) @@ -457,8 +660,7 @@ func TestPublicAPIWithEnvContractMapsBindings(t *testing.T) { ) require.NoError(t, err) - snapshot, err := store.Snapshot(owl.SnapshotPolicy{}) - require.NoError(t, err) + snapshot := snapshotItems(t, store, owl.SnapshotPolicy{}) item := snapshotByName(snapshot)["DATABASE_URL"] assert.Equal(t, "postgres://example", item.Value) assert.Equal(t, owl.TypeCoreURL, item.Type) @@ -491,8 +693,7 @@ func TestPublicAPIWithConfigMapsRedisRequirement(t *testing.T) { ) require.NoError(t, err) - snapshot, err := store.Snapshot(owl.SnapshotPolicy{}) - require.NoError(t, err) + snapshot := snapshotItems(t, store, owl.SnapshotPolicy{}) byName := snapshotByName(snapshot) assert.Equal(t, "localhost", byName["QUEUES_REDIS_HOST"].Value) @@ -511,7 +712,7 @@ func TestPublicAPIWithConfigMapsRedisRequirement(t *testing.T) { assert.Equal(t, owl.VisibilityUnresolved, byName["REDIS_AUTH_TOKEN"].Visibility) assert.Equal(t, "[config]", byName["REDIS_AUTH_TOKEN"].Origin.Name) - dotenvSpec, err := store.DotenvSpec() + dotenvSpec, err := store.DotenvSpec(context.Background(), owl.DotenvSpecInput{}) require.NoError(t, err) assert.Equal(t, strings.Join([]string{ "# Generated by Owl from Owl config. Do not edit by hand.", @@ -520,9 +721,9 @@ func TestPublicAPIWithConfigMapsRedisRequirement(t *testing.T) { `QUEUES_REDIS_PORT="Redis server port" # Plain!`, `REDIS_AUTH_TOKEN="Redis password" # Secret!`, "", - }, "\n"), dotenvSpec) + }, "\n"), dotenvSpec.Rendered) - check := store.Check() + check := checkStore(t, store) assert.False(t, check.OK) assert.Contains(t, diagnosticCodes(check.Diagnostics), "dotenv.unresolved-required") } @@ -544,11 +745,11 @@ func TestPublicAPIWithConfigValidatesRedisPort(t *testing.T) { ) require.NoError(t, err) - check := store.Check() + check := checkStore(t, store) assert.False(t, check.OK) assert.Contains(t, diagnosticCodes(check.Diagnostics), "type.invalid-port") - port, ok, err := store.Get("QUEUES_REDIS_PORT", owl.GetPolicy{Reveal: true}) + port, ok, err := store.Get(context.Background(), owl.GetInput{Key: "QUEUES_REDIS_PORT", Policy: owl.GetPolicy{Reveal: true}}) require.NoError(t, err) require.True(t, ok) assert.Equal(t, `universe/redis("queues").port`, port.Field.String()) @@ -571,7 +772,7 @@ func TestPublicAPIWithConfigValidatesRedisHostRequiredByRedis(t *testing.T) { ) require.NoError(t, err) - check := store.Check() + check := checkStore(t, store) assert.False(t, check.OK) assert.Contains(t, diagnosticCodes(check.Diagnostics), "type.invalid-host") } @@ -593,8 +794,7 @@ func TestPublicAPIWithConfigIncludesRedisHostDiagnosticsInSnapshot(t *testing.T) ) require.NoError(t, err) - snapshot, err := store.Snapshot(owl.SnapshotPolicy{}) - require.NoError(t, err) + snapshot := snapshotItems(t, store, owl.SnapshotPolicy{}) byName := snapshotByName(snapshot) assert.Contains(t, diagnosticCodes(byName["QUEUES_REDIS_HOST"].Diagnostics), "type.invalid-host") } @@ -630,8 +830,7 @@ func TestPublicAPIResolveReturnsPromptActionsAndAppliesAnswers(t *testing.T) { require.Len(t, applied.Attempts, 1) assert.Equal(t, owl.ResolverResolved, applied.Attempts[0].Outcome) - snapshot, err := store.Snapshot(owl.SnapshotPolicy{}) - require.NoError(t, err) + snapshot := snapshotItems(t, store, owl.SnapshotPolicy{}) byName := snapshotByName(snapshot) assert.Equal(t, "[masked]", byName["API_KEY"].Value) assert.Equal(t, "[interactive]", byName["API_KEY"].Source.Name) diff --git a/pkg/owl/seed/seed.go b/pkg/owl/seed/seed.go index 0d0e7a6..e028427 100644 --- a/pkg/owl/seed/seed.go +++ b/pkg/owl/seed/seed.go @@ -16,6 +16,7 @@ type Options struct { WorkDir string Direnv DirenvPolicy DirenvRunner DirenvExportRunner + TypeProvider owl.TypeProvider } // ObservedSource is an environment-shaped snapshot with caller-provided provenance. @@ -42,9 +43,12 @@ type DirenvExportRunner = internalseed.DirenvExportRunner // Result is the seeded store plus source diagnostics used to build it. type Result struct { Store *owl.Store + Catalog Catalog Diagnostics []owl.Diagnostic } +type Catalog = internalseed.Catalog + // NewStore creates an Owl store and resolves values from observed sources. func NewStore(ctx context.Context, opts Options) (*Result, error) { result, err := internalseed.NewStore(ctx, internalOptions(opts)) @@ -59,10 +63,19 @@ func NewStore(ctx context.Context, opts Options) (*Result, error) { } return &Result{ Store: result.Store, + Catalog: result.Catalog, Diagnostics: result.Diagnostics, }, nil } +func NewRawValueStore(opts Options, allowMissingSpec bool) (*owl.Store, error) { + return internalseed.NewRawValueStore(internalOptions(opts), allowMissingSpec) +} + +func BuildCatalog(ctx context.Context, opts Options) (Catalog, []owl.Diagnostic, error) { + return internalseed.BuildCatalog(ctx, internalOptions(opts)) +} + func internalOptions(opts Options) internalseed.Options { observed := make([]internalseed.ObservedSource, 0, len(opts.Observed)) for _, source := range opts.Observed { @@ -79,5 +92,6 @@ func internalOptions(opts Options) internalseed.Options { WorkDir: opts.WorkDir, Direnv: opts.Direnv, DirenvRunner: opts.DirenvRunner, + TypeProvider: opts.TypeProvider, } } diff --git a/pkg/owl/seed/seed_test.go b/pkg/owl/seed/seed_test.go index 1b62d21..0ea7828 100644 --- a/pkg/owl/seed/seed_test.go +++ b/pkg/owl/seed/seed_test.go @@ -12,6 +12,16 @@ import ( "github.com/runmedev/owl/pkg/owl" ) +func snapshotItems(t *testing.T, store *owl.Store, policy owl.SnapshotPolicy) []owl.SnapshotItem { + t.Helper() + output, err := store.Snapshot(context.Background(), owl.SnapshotInput{ + Policy: policy, + Filter: owl.SnapshotFilter{All: true}, + }) + require.NoError(t, err) + return output.Envs +} + func TestNewStoreReturnsSeededStore(t *testing.T) { t.Parallel() @@ -28,8 +38,7 @@ func TestNewStoreReturnsSeededStore(t *testing.T) { require.NoError(t, err) require.Empty(t, result.Diagnostics) - items, err := result.Store.Snapshot(owl.SnapshotPolicy{Reveal: true}) - require.NoError(t, err) + items := snapshotItems(t, result.Store, owl.SnapshotPolicy{Reveal: true}) require.Len(t, items, 1) assert.Equal(t, "API_KEY", items[0].Name) assert.Equal(t, "secret", items[0].Value) @@ -52,8 +61,7 @@ func TestNewStoreSkipsMissingEnvFiles(t *testing.T) { require.NoError(t, err) require.Empty(t, result.Diagnostics) - items, err := result.Store.Snapshot(owl.SnapshotPolicy{Reveal: true}) - require.NoError(t, err) + items := snapshotItems(t, result.Store, owl.SnapshotPolicy{Reveal: true}) require.Len(t, items, 1) assert.Equal(t, "PRESENT", items[0].Name) assert.Equal(t, "from-dotenv", items[0].Value) @@ -79,8 +87,7 @@ func TestNewStoreUsesDefaultEnvFilesInOrder(t *testing.T) { require.NoError(t, err) require.Empty(t, result.Diagnostics) - items, err := result.Store.Snapshot(owl.SnapshotPolicy{Reveal: true}) - require.NoError(t, err) + items := snapshotItems(t, result.Store, owl.SnapshotPolicy{Reveal: true}) require.Len(t, items, 1) assert.Equal(t, "DEFAULT_ORDER", items[0].Name) assert.Equal(t, "from-env-dev", items[0].Value) diff --git a/internal/version/version.go b/pkg/owl/version.go similarity index 92% rename from internal/version/version.go rename to pkg/owl/version.go index 1ed6e93..6ec617a 100644 --- a/internal/version/version.go +++ b/pkg/owl/version.go @@ -1,4 +1,4 @@ -package version +package owl import "fmt"