From 15a82280b20035e223b5c81ae88c74cac3245c32 Mon Sep 17 00:00:00 2001 From: Nagesh Bansal Date: Thu, 20 Aug 2026 12:40:48 +0530 Subject: [PATCH] feat(config): forge each casting document against a reconciled lock --- cmd/foundryctl/cast.go | 9 +- cmd/foundryctl/forge.go | 13 +- internal/config/config.go | 10 +- internal/config/yamlconfig/config.go | 350 ++++++++++++++-------- internal/config/yamlconfig/config_test.go | 217 ++++++++++++-- internal/foundry/cast.go | 22 +- internal/foundry/forge.go | 99 +++--- internal/foundry/foundry.go | 23 +- internal/foundry/gauge.go | 12 +- 9 files changed, 510 insertions(+), 245 deletions(-) diff --git a/cmd/foundryctl/cast.go b/cmd/foundryctl/cast.go index e09685a7..b149abb1 100644 --- a/cmd/foundryctl/cast.go +++ b/cmd/foundryctl/cast.go @@ -63,6 +63,11 @@ func runCast(ctx context.Context, logger *slog.Logger, poursPath string, configP } } - err = foundry.Cast(ctx, machineries, poursPath) - return props, err + for _, machinery := range machineries { + if err := foundry.Cast(ctx, machinery, poursPath); err != nil { + return props, err + } + } + + return props, nil } diff --git a/cmd/foundryctl/forge.go b/cmd/foundryctl/forge.go index 105d6593..bbd66e76 100644 --- a/cmd/foundryctl/forge.go +++ b/cmd/foundryctl/forge.go @@ -37,6 +37,10 @@ func runForge(ctx context.Context, logger *slog.Logger, path string, poursPath s return domain.NewProperties(), err } + if err := foundry.Config.PruneV1Alpha1Lock(ctx, machineries, path); err != nil { + return domain.NewProperties(), err + } + props := domain.NewProperties() for _, machinery := range machineries { if machinery.Kind() == v1alpha1.KindInstallation { @@ -49,6 +53,11 @@ func runForge(ctx context.Context, logger *slog.Logger, path string, poursPath s return props, err } - err = foundry.Forge(ctx, machineries, path, &writer.Options{Output: &os.File{}, TargetDirectory: poursAbsPath}) - return props, err + for _, machinery := range machineries { + if err := foundry.Forge(ctx, machinery, path, &writer.Options{Output: &os.File{}, TargetDirectory: poursAbsPath}); err != nil { + return props, err + } + } + + return props, nil } diff --git a/internal/config/config.go b/internal/config/config.go index 04b1013b..bc8e5c80 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -11,8 +11,14 @@ type Config interface { // document the file holds, and returns them in cast order. GetV1Alpha1(ctx context.Context, path string) ([]v1alpha1.Machinery, error) - // CreateV1Alpha1Lock writes the resolved castings to the lock file. - CreateV1Alpha1Lock(ctx context.Context, machineries []v1alpha1.Machinery, path string) error + // CreateOrUpdateV1Alpha1Lock replaces the casting's entry in the lock file, + // keeping every other entry. A missing lock starts empty. + CreateOrUpdateV1Alpha1Lock(ctx context.Context, machinery v1alpha1.Machinery, path string) error + + // PruneV1Alpha1Lock drops lock entries for castings the file no longer + // declares; entries for declared castings are untouched. A lock left with + // no entries is removed. + PruneV1Alpha1Lock(ctx context.Context, declared []v1alpha1.Machinery, path string) error // GetV1Alpha1Lock reads the lock file from disk. GetV1Alpha1Lock(ctx context.Context, path string) ([]v1alpha1.Machinery, error) diff --git a/internal/config/yamlconfig/config.go b/internal/config/yamlconfig/config.go index ed57f4a6..e804260a 100644 --- a/internal/config/yamlconfig/config.go +++ b/internal/config/yamlconfig/config.go @@ -6,6 +6,7 @@ import ( "log/slog" "os" "path/filepath" + "sort" "github.com/google/jsonschema-go/jsonschema" "github.com/signoz/foundry/api/v1alpha1" @@ -26,105 +27,6 @@ type yamlConfig struct { loaders map[v1alpha1.Kind]loader } -// loader is one Kind's entry in the table. Supporting a new Kind is one entry -// here and its place in v1alpha1.Kinds(). -type loader struct { - kind v1alpha1.Kind - - new func() v1alpha1.Machinery - - // defaults returns the Kind's baseline. It reads the declaration because a - // Kind may default one component from another's declared kind. - defaults func(declared v1alpha1.Machinery) v1alpha1.Machinery - - schema func() *jsonschema.Resolved - - // check is the Kind's compatibility gate; nil when it has none. - check func(casting v1alpha1.Machinery) error - - // discriminator tells the Kind's documents apart; nil for a Kind that - // holds one document per file. - discriminator *discriminator -} - -// discriminator tells two documents of a Kind apart, so a casting file may -// hold one document per value; name is the property errors call it by. -type discriminator struct { - name string - of func(casting v1alpha1.Machinery) string -} - -// reader turns one document into its casting: loader.resolve for the casting -// file, loader.read for the lock. -type reader func(loader, []byte) (v1alpha1.Machinery, error) - -// resolve lays the Kind's defaults under the declaration, merges the -// declaration over them, validates the result against the Kind's schema, and -// runs the Kind's compatibility check. -func (l loader) resolve(document []byte) (v1alpha1.Machinery, error) { - declared := l.new() - if err := domain.UnmarshalYAML(document, declared); err != nil { - return nil, errors.Wrapf(err, errors.TypeInvalidInput, "failed to unmarshal %s casting", l.kind) - } - - casting := l.defaults(declared) - if err := v1alpha1.Merge(casting, declared); err != nil { - return nil, errors.Wrapf(err, errors.TypeInternal, "failed to merge %s casting over its defaults", l.kind) - } - - // The schema describes the JSON shape, so the casting validates as JSON. - contents, err := json.Marshal(casting) - if err != nil { - return nil, errors.Wrapf(err, errors.TypeInternal, "failed to marshal %s casting", l.kind) - } - - toValidate := map[string]any{} - if err := json.Unmarshal(contents, &toValidate); err != nil { - return nil, errors.Wrapf(err, errors.TypeInternal, "failed to unmarshal %s casting for validation", l.kind) - } - - if err := l.schema().Validate(toValidate); err != nil { - return nil, errors.Wrapf(err, errors.TypeInvalidInput, "failed to validate %s casting against its schema", l.kind) - } - - if l.check != nil { - if err := l.check(casting); err != nil { - return nil, err - } - } - - return casting, nil -} - -// read unmarshals an already-resolved casting; the lock's documents take -// neither defaults nor validation. -func (l loader) read(document []byte) (v1alpha1.Machinery, error) { - casting := l.new() - if err := domain.UnmarshalYAML(document, casting); err != nil { - return nil, errors.Wrapf(err, errors.TypeInvalidInput, "failed to unmarshal %s casting", l.kind) - } - - return casting, nil -} - -// collide rejects a casting whose pours would overwrite an already-declared -// one's: a second document of a Kind without a discriminator, or one with the -// same discriminator value. Values are compared after defaults are merged, so -// an omitted value collides with an explicitly declared default. -func (l loader) collide(casting v1alpha1.Machinery, declared []v1alpha1.Machinery) error { - for _, existing := range declared { - if l.discriminator == nil { - return errors.Newf(errors.TypeInvalidInput, "%s is already declared as %q: a casting file holds one %s", l.kind, existing.Name(), l.kind) - } - - if value := l.discriminator.of(casting); value == l.discriminator.of(existing) { - return errors.Newf(errors.TypeInvalidInput, "%s is already declared as %q: a casting file holds one %s per %s and both declare %q", l.kind, existing.Name(), l.kind, l.discriminator.name, value) - } - } - - return nil -} - func New(logger *slog.Logger) *yamlConfig { loaders := []loader{ { @@ -166,8 +68,6 @@ func New(logger *slog.Logger) *yamlConfig { return &yamlConfig{loaders: byKind} } -// GetV1Alpha1 reads, dispatches, and validates every casting document the file -// holds, and returns the resolved castings in cast order. func (config *yamlConfig) GetV1Alpha1(ctx context.Context, path string) ([]v1alpha1.Machinery, error) { contents, err := os.ReadFile(path) if err != nil { @@ -177,38 +77,55 @@ func (config *yamlConfig) GetV1Alpha1(ctx context.Context, path string) ([]v1alp return config.castings(contents, path, loader.resolve) } -// GetV1Alpha1Lock reads the lock beside the casting file; its documents are -// already resolved, so they take neither defaults nor validation. -func (config *yamlConfig) GetV1Alpha1Lock(ctx context.Context, path string) ([]v1alpha1.Machinery, error) { - lockPath := filepath.Join(filepath.Dir(path), lockFileName) +func (config *yamlConfig) CreateOrUpdateV1Alpha1Lock(ctx context.Context, machinery v1alpha1.Machinery, path string) error { + lockPath := lockPathFor(path) - contents, err := os.ReadFile(lockPath) + locked, err := config.readLock(lockPath) if err != nil { - return nil, errors.Wrapf(err, errors.TypeNotFound, "failed to read lock file") + return err } - return config.castings(contents, lockPath, loader.read) + locked[config.identityOf(machinery)] = machinery + + return config.writeLock(lockPath, locked) } -// CreateV1Alpha1Lock writes the resolved castings beside the casting file, one -// document each, in the order they were resolved. -func (*yamlConfig) CreateV1Alpha1Lock(ctx context.Context, machineries []v1alpha1.Machinery, path string) error { - documents := make([][]byte, 0, len(machineries)) +func (config *yamlConfig) PruneV1Alpha1Lock(ctx context.Context, declared []v1alpha1.Machinery, path string) error { + lockPath := lockPathFor(path) - for _, machinery := range machineries { - contents, err := domain.MarshalYAML(machinery) - if err != nil { - return errors.Wrapf(err, errors.TypeInternal, "failed to marshal %s casting %q", machinery.Kind(), machinery.Name()) + locked, err := config.readLock(lockPath) + if err != nil { + return err + } + + identities := make(map[string]struct{}, len(declared)) + for _, m := range declared { + identities[config.identityOf(m)] = struct{}{} + } + + before := len(locked) + for identity := range locked { + if _, ok := identities[identity]; !ok { + delete(locked, identity) } + } - documents = append(documents, contents) + if len(locked) == before { + return nil } - if err := os.WriteFile(filepath.Join(filepath.Dir(path), lockFileName), domain.NewYAMLStream(documents), 0644); err != nil { - return errors.Wrapf(err, errors.TypeInternal, "failed to write lock file") + return config.writeLock(lockPath, locked) +} + +func (config *yamlConfig) GetV1Alpha1Lock(ctx context.Context, path string) ([]v1alpha1.Machinery, error) { + lockPath := lockPathFor(path) + + contents, err := os.ReadFile(lockPath) + if err != nil { + return nil, errors.Wrapf(err, errors.TypeNotFound, "failed to read lock file") } - return nil + return config.castings(contents, lockPath, loader.read) } // castings reads every casting document in the stream and returns them in cast @@ -272,3 +189,196 @@ func peekKind(document []byte) (v1alpha1.Kind, error) { return probe.Kind, nil } + +func (config *yamlConfig) identityOf(machinery v1alpha1.Machinery) string { + return config.loaders[machinery.Kind()].identity(machinery) +} + +// lockPathFor is the lock's path beside the casting file. +func lockPathFor(path string) string { + return filepath.Join(filepath.Dir(path), lockFileName) +} + +// readLock maps the locked castings by identity; a missing or empty lock is +// empty. +func (config *yamlConfig) readLock(lockPath string) (map[string]v1alpha1.Machinery, error) { + locked := map[string]v1alpha1.Machinery{} + + contents, err := os.ReadFile(lockPath) + switch { + case os.IsNotExist(err): + return locked, nil + case err != nil: + return nil, errors.Wrapf(err, errors.TypeInternal, "failed to read lock file") + } + + if len(contents) == 0 { + return locked, nil + } + + machineries, err := config.castings(contents, lockPath, loader.read) + if err != nil { + return nil, err + } + + for _, m := range machineries { + locked[config.identityOf(m)] = m + } + + return locked, nil +} + +// writeLock writes the entries in cast order, a kind's own entries sorted by +// identity, through a temporary file so a crash never truncates the lock. Zero +// entries remove the lock: nothing locked and no lock are the same state. +func (config *yamlConfig) writeLock(lockPath string, locked map[string]v1alpha1.Machinery) error { + if len(locked) == 0 { + if err := os.Remove(lockPath); err != nil && !os.IsNotExist(err) { + return errors.Wrapf(err, errors.TypeInternal, "failed to remove lock file") + } + + return nil + } + + byKind := make(map[v1alpha1.Kind][]string, len(locked)) + for identity, m := range locked { + byKind[m.Kind()] = append(byKind[m.Kind()], identity) + } + + documents := make([][]byte, 0, len(locked)) + for _, kind := range v1alpha1.Kinds() { + identities := byKind[kind] + sort.Strings(identities) + + for _, identity := range identities { + m := locked[identity] + + contents, err := domain.MarshalYAML(m) + if err != nil { + return errors.Wrapf(err, errors.TypeInternal, "failed to marshal %s casting %q", m.Kind(), m.Name()) + } + + documents = append(documents, contents) + } + } + + tmpPath := lockPath + ".tmp" + if err := os.WriteFile(tmpPath, domain.NewYAMLStream(documents), 0644); err != nil { + return errors.Wrapf(err, errors.TypeInternal, "failed to write lock file") + } + + if err := os.Rename(tmpPath, lockPath); err != nil { + return errors.Wrapf(err, errors.TypeInternal, "failed to write lock file") + } + + return nil +} + +// loader is one Kind's entry in the table. Supporting a new Kind is one entry +// here and its place in v1alpha1.Kinds(). +type loader struct { + kind v1alpha1.Kind + + new func() v1alpha1.Machinery + + // defaults reads the declaration because a Kind may default one component + // from another's declared kind. + defaults func(declared v1alpha1.Machinery) v1alpha1.Machinery + + schema func() *jsonschema.Resolved + + // check is the Kind's compatibility gate; nil when it has none. + check func(casting v1alpha1.Machinery) error + + // discriminator tells the Kind's documents apart; nil for a Kind that + // holds one document per file. + discriminator *discriminator +} + +type discriminator struct { + // name is the property errors call the discriminator by. + name string + of func(casting v1alpha1.Machinery) string +} + +// reader turns one document into its casting: loader.resolve for the casting +// file, loader.read for the lock. +type reader func(loader, []byte) (v1alpha1.Machinery, error) + +// resolve lays the Kind's defaults under the declaration, merges the +// declaration over them, validates the result against the Kind's schema, and +// runs the Kind's compatibility check. +func (l loader) resolve(document []byte) (v1alpha1.Machinery, error) { + declared := l.new() + if err := domain.UnmarshalYAML(document, declared); err != nil { + return nil, errors.Wrapf(err, errors.TypeInvalidInput, "failed to unmarshal %s casting", l.kind) + } + + casting := l.defaults(declared) + if err := v1alpha1.Merge(casting, declared); err != nil { + return nil, errors.Wrapf(err, errors.TypeInternal, "failed to merge %s casting over its defaults", l.kind) + } + + // The schema describes the JSON shape, so the casting validates as JSON. + contents, err := json.Marshal(casting) + if err != nil { + return nil, errors.Wrapf(err, errors.TypeInternal, "failed to marshal %s casting", l.kind) + } + + toValidate := map[string]any{} + if err := json.Unmarshal(contents, &toValidate); err != nil { + return nil, errors.Wrapf(err, errors.TypeInternal, "failed to unmarshal %s casting for validation", l.kind) + } + + if err := l.schema().Validate(toValidate); err != nil { + return nil, errors.Wrapf(err, errors.TypeInvalidInput, "failed to validate %s casting against its schema", l.kind) + } + + if l.check != nil { + if err := l.check(casting); err != nil { + return nil, err + } + } + + return casting, nil +} + +// read unmarshals an already-resolved casting; the lock's documents take +// neither defaults nor validation. +func (l loader) read(document []byte) (v1alpha1.Machinery, error) { + casting := l.new() + if err := domain.UnmarshalYAML(document, casting); err != nil { + return nil, errors.Wrapf(err, errors.TypeInvalidInput, "failed to unmarshal %s casting", l.kind) + } + + return casting, nil +} + +// identity is the casting's collision identity: its kind, qualified by the +// Kind's discriminator value when the loader declares one. +func (l loader) identity(casting v1alpha1.Machinery) string { + if l.discriminator == nil { + return casting.Kind().String() + } + + return casting.Kind().String() + "/" + l.discriminator.of(casting) +} + +// collide rejects a casting whose pours would overwrite an already-declared +// one's: one sharing its identity. Identities compare after defaults merge, so +// an omitted discriminator value collides with a declared default. +func (l loader) collide(casting v1alpha1.Machinery, declared []v1alpha1.Machinery) error { + for _, existing := range declared { + if l.identity(casting) != l.identity(existing) { + continue + } + + if l.discriminator == nil { + return errors.Newf(errors.TypeInvalidInput, "%s is already declared as %q: a casting file holds one %s", l.kind, existing.Name(), l.kind) + } + + return errors.Newf(errors.TypeInvalidInput, "%s is already declared as %q: a casting file holds one %s per %s and both declare %q", l.kind, existing.Name(), l.kind, l.discriminator.name, l.discriminator.of(casting)) + } + + return nil +} diff --git a/internal/config/yamlconfig/config_test.go b/internal/config/yamlconfig/config_test.go index 2a98c3e4..c9c3fbcd 100644 --- a/internal/config/yamlconfig/config_test.go +++ b/internal/config/yamlconfig/config_test.go @@ -6,6 +6,7 @@ import ( "os" "path/filepath" "testing" + "time" "github.com/signoz/foundry/api/v1alpha1" "github.com/signoz/foundry/api/v1alpha1/infrastructure" @@ -637,10 +638,32 @@ spec: } } -func TestCreateV1Alpha1Lock(t *testing.T) { +func TestCreateOrUpdateV1Alpha1Lock(t *testing.T) { + // Written agent-first, so the lock proves it records cast order. + twoDocuments := ` +apiVersion: v1alpha1 +kind: CollectionAgent +metadata: + name: signoz-agent +spec: + deployment: + mode: docker + flavor: compose +--- +apiVersion: v1alpha1 +kind: Installation +metadata: + name: signoz +spec: + deployment: + mode: docker + flavor: compose +` + tests := []struct { name string contents string + records []int expectedKinds []v1alpha1.Kind expectedNames []string }{ @@ -656,31 +679,35 @@ spec: mode: docker flavor: compose `, + records: []int{0}, expectedKinds: []v1alpha1.Kind{v1alpha1.KindInstallation}, expectedNames: []string{"signoz"}, }, { - // Written agent-first, so the lock proves it records cast order. - name: "TwoDocuments_RoundTripInCastOrder", - contents: ` -apiVersion: v1alpha1 -kind: CollectionAgent -metadata: - name: signoz-agent -spec: - deployment: - mode: docker - flavor: compose ---- -apiVersion: v1alpha1 -kind: Installation -metadata: - name: signoz -spec: - deployment: - mode: docker - flavor: compose -`, + name: "TwoDocuments_RoundTripInCastOrder", + contents: twoDocuments, + records: []int{0, 1}, + expectedKinds: []v1alpha1.Kind{v1alpha1.KindInstallation, v1alpha1.KindCollectionAgent}, + expectedNames: []string{"signoz", "signoz-agent"}, + }, + { + name: "PartialRun_LocksOnlyWhatForged", + contents: twoDocuments, + records: []int{0}, + expectedKinds: []v1alpha1.Kind{v1alpha1.KindInstallation}, + expectedNames: []string{"signoz"}, + }, + { + name: "LaterPartialRun_KeepsOtherEntries", + contents: twoDocuments, + records: []int{0, 1, 0}, + expectedKinds: []v1alpha1.Kind{v1alpha1.KindInstallation, v1alpha1.KindCollectionAgent}, + expectedNames: []string{"signoz", "signoz-agent"}, + }, + { + name: "OutOfOrderRecords_LockKeepsCastOrder", + contents: twoDocuments, + records: []int{1, 0}, expectedKinds: []v1alpha1.Kind{v1alpha1.KindInstallation, v1alpha1.KindCollectionAgent}, expectedNames: []string{"signoz", "signoz-agent"}, }, @@ -717,6 +744,7 @@ spec: mode: ec2 flavor: terraform `, + records: []int{0, 1, 2}, expectedKinds: []v1alpha1.Kind{v1alpha1.KindInfrastructure, v1alpha1.KindInstallation, v1alpha1.KindCollectionAgent}, expectedNames: []string{"signoz-infra", "signoz", "signoz-agent"}, }, @@ -732,7 +760,9 @@ spec: machineries, err := cfg.GetV1Alpha1(ctx, castingPath) assert.NoError(t, err) - assert.NoError(t, cfg.CreateV1Alpha1Lock(ctx, machineries, castingPath)) + for _, i := range tt.records { + assert.NoError(t, cfg.CreateOrUpdateV1Alpha1Lock(ctx, machineries[i], castingPath)) + } locked, err := cfg.GetV1Alpha1Lock(ctx, castingPath) assert.NoError(t, err) @@ -748,4 +778,145 @@ spec: assert.Equal(t, tt.expectedNames, names) }) } + + // A zero-byte lock and a missing lock state the same fact, no entries + // recorded, so a lock truncated outside foundry reads as empty and the + // next forge rebuilds it instead of failing every later run. + t.Run("EmptyLockFile_TreatedAsMissing", func(t *testing.T) { + ctx := context.Background() + castingPath := filepath.Join(t.TempDir(), "casting.yaml") + assert.NoError(t, os.WriteFile(castingPath, []byte(tests[0].contents), 0644)) + assert.NoError(t, os.WriteFile(filepath.Join(filepath.Dir(castingPath), "casting.yaml.lock"), nil, 0644)) + + cfg := New(slog.New(slog.DiscardHandler)) + + machineries, err := cfg.GetV1Alpha1(ctx, castingPath) + assert.NoError(t, err) + assert.NoError(t, cfg.CreateOrUpdateV1Alpha1Lock(ctx, machineries[0], castingPath)) + + locked, err := cfg.GetV1Alpha1Lock(ctx, castingPath) + assert.NoError(t, err) + assert.Len(t, locked, 1) + }) +} + +func TestPruneV1Alpha1Lock(t *testing.T) { + twoDocuments := ` +apiVersion: v1alpha1 +kind: Installation +metadata: + name: signoz +spec: + deployment: + mode: docker + flavor: compose +--- +apiVersion: v1alpha1 +kind: CollectionAgent +metadata: + name: signoz-agent +spec: + deployment: + mode: docker + flavor: compose +` + + lock := func(t *testing.T, contents string) (*yamlConfig, []v1alpha1.Machinery, string) { + t.Helper() + + ctx := context.Background() + castingPath := filepath.Join(t.TempDir(), "casting.yaml") + assert.NoError(t, os.WriteFile(castingPath, []byte(contents), 0644)) + + cfg := New(slog.New(slog.DiscardHandler)) + + machineries, err := cfg.GetV1Alpha1(ctx, castingPath) + assert.NoError(t, err) + for _, machinery := range machineries { + assert.NoError(t, cfg.CreateOrUpdateV1Alpha1Lock(ctx, machinery, castingPath)) + } + + return cfg, machineries, castingPath + } + + t.Run("RemovedKind_EntryDropped", func(t *testing.T) { + ctx := context.Background() + cfg, machineries, castingPath := lock(t, twoDocuments) + + assert.NoError(t, cfg.PruneV1Alpha1Lock(ctx, machineries[:1], castingPath)) + + locked, err := cfg.GetV1Alpha1Lock(ctx, castingPath) + assert.NoError(t, err) + assert.Len(t, locked, 1) + assert.Equal(t, v1alpha1.KindInstallation, locked[0].Kind()) + }) + + t.Run("AllDeclared_NoRewrite", func(t *testing.T) { + ctx := context.Background() + cfg, machineries, castingPath := lock(t, twoDocuments) + + lockPath := filepath.Join(filepath.Dir(castingPath), "casting.yaml.lock") + past := time.Now().Add(-time.Hour) + assert.NoError(t, os.Chtimes(lockPath, past, past)) + + assert.NoError(t, cfg.PruneV1Alpha1Lock(ctx, machineries, castingPath)) + + info, err := os.Stat(lockPath) + assert.NoError(t, err) + assert.True(t, info.ModTime().Equal(past), "prune must not rewrite an unchanged lock") + }) + + t.Run("AllUndeclared_LockRemovedAndUpsertRecovers", func(t *testing.T) { + ctx := context.Background() + cfg, _, castingPath := lock(t, ` +apiVersion: v1alpha1 +kind: CollectionAgent +metadata: + name: signoz-agent +spec: + deployment: + mode: docker + flavor: compose +`) + + assert.NoError(t, os.WriteFile(castingPath, []byte(` +apiVersion: v1alpha1 +kind: Installation +metadata: + name: signoz +spec: + deployment: + mode: docker + flavor: compose +`), 0644)) + + declared, err := cfg.GetV1Alpha1(ctx, castingPath) + assert.NoError(t, err) + assert.NoError(t, cfg.PruneV1Alpha1Lock(ctx, declared, castingPath)) + + _, err = os.Stat(filepath.Join(filepath.Dir(castingPath), "casting.yaml.lock")) + assert.True(t, os.IsNotExist(err), "a lock left with no entries must be removed") + + assert.NoError(t, cfg.CreateOrUpdateV1Alpha1Lock(ctx, declared[0], castingPath)) + + locked, err := cfg.GetV1Alpha1Lock(ctx, castingPath) + assert.NoError(t, err) + assert.Len(t, locked, 1) + assert.Equal(t, v1alpha1.KindInstallation, locked[0].Kind()) + }) + + t.Run("MissingLock_NoOp", func(t *testing.T) { + ctx := context.Background() + castingPath := filepath.Join(t.TempDir(), "casting.yaml") + assert.NoError(t, os.WriteFile(castingPath, []byte(twoDocuments), 0644)) + + cfg := New(slog.New(slog.DiscardHandler)) + + machineries, err := cfg.GetV1Alpha1(ctx, castingPath) + assert.NoError(t, err) + assert.NoError(t, cfg.PruneV1Alpha1Lock(ctx, machineries, castingPath)) + + _, err = os.Stat(filepath.Join(filepath.Dir(castingPath), "casting.yaml.lock")) + assert.True(t, os.IsNotExist(err), "prune must not create a lock") + }) } diff --git a/internal/foundry/cast.go b/internal/foundry/cast.go index dae00bce..aad3fb42 100644 --- a/internal/foundry/cast.go +++ b/internal/foundry/cast.go @@ -7,25 +7,15 @@ import ( "github.com/signoz/foundry/api/v1alpha1" ) -// Cast casts every document of the set, in the order the lock records. A -// document that fails stops the run; what already cast stays cast. -func (foundry *Foundry) Cast(ctx context.Context, machineries []v1alpha1.Machinery, poursPath string) error { - planners, err := foundry.Plan(ctx, machineries) +func (foundry *Foundry) Cast(ctx context.Context, machinery v1alpha1.Machinery, poursPath string) error { + p, err := foundry.Plan(ctx, machinery) if err != nil { return err } - for _, p := range planners { - machinery := p.Machinery() + foundry.Logger.InfoContext(ctx, "casting", + slog.String("casting.kind", machinery.Kind().String()), + slog.String("casting.metadata.name", machinery.Name())) - foundry.Logger.InfoContext(ctx, "casting", - slog.String("casting.kind", machinery.Kind().String()), - slog.String("casting.metadata.name", machinery.Name())) - - if err := p.Cast(ctx, poursPath); err != nil { - return err - } - } - - return nil + return p.Cast(ctx, poursPath) } diff --git a/internal/foundry/forge.go b/internal/foundry/forge.go index 107e3009..befed9d8 100644 --- a/internal/foundry/forge.go +++ b/internal/foundry/forge.go @@ -5,83 +5,70 @@ import ( "log/slog" "github.com/signoz/foundry/api/v1alpha1" - "github.com/signoz/foundry/internal/domain" foundryerrors "github.com/signoz/foundry/internal/errors" "github.com/signoz/foundry/internal/writer" ) -// Forge resolves every document of the casting file, records them all in one -// lock, and writes their materials. A document that fails takes the run with -// it, so no lock is written for a set that did not forge whole. -func (foundry *Foundry) Forge(ctx context.Context, machineries []v1alpha1.Machinery, path string, poursWriterOpts *writer.Options) error { - planners, err := foundry.Plan(ctx, machineries) +// Forge runs one casting document through the pipeline (enrich, mold, merge +// status into spec, forge, patch), pours its materials, and records it in the +// lock. +func (foundry *Foundry) Forge(ctx context.Context, machinery v1alpha1.Machinery, path string, poursWriterOpts *writer.Options) error { + p, err := foundry.Plan(ctx, machinery) if err != nil { return err } - materials := []domain.Material{} + foundry.Logger.InfoContext(ctx, "forging casting", + slog.String("casting.kind", machinery.Kind().String()), + slog.String("casting.metadata.name", machinery.Name())) - for _, p := range planners { - machinery := p.Machinery() - - foundry.Logger.InfoContext(ctx, "forging casting", - slog.String("casting.kind", machinery.Kind().String()), - slog.String("casting.metadata.name", machinery.Name())) - - for _, kind := range p.MoldingKinds() { - if err := p.EnrichStatus(ctx, kind); err != nil { - return foundryerrors.Wrapf(err, foundryerrors.TypeInternal, "failed to enrich molding %s", kind) - } - } - - for _, kind := range p.MoldingKinds() { - foundry.Logger.InfoContext(ctx, "molding configuration for kind", slog.String("molding.kind", kind.String())) - if err := p.Mold(ctx, kind); err != nil { - return err - } - } - - if err := p.MergeStatusIntoSpec(); err != nil { - return err + for _, kind := range p.MoldingKinds() { + if err := p.EnrichStatus(ctx, kind); err != nil { + return foundryerrors.Wrapf(err, foundryerrors.TypeInternal, "failed to enrich molding %s", kind) } + } - forged, err := p.Forge(ctx, poursWriterOpts.TargetDirectory) - if err != nil { + for _, kind := range p.MoldingKinds() { + foundry.Logger.InfoContext(ctx, "molding configuration for kind", slog.String("molding.kind", kind.String())) + if err := p.Mold(ctx, kind); err != nil { return err } + } - for _, pe := range p.Patches() { - patcher, ok := foundry.Patchers[pe.PatchType()] - if !ok { - return foundryerrors.Newf(foundryerrors.TypeUnsupported, "unknown patch type %q", pe.PatchType()) - } - foundry.Logger.InfoContext(ctx, "applying patch", slog.String("patch.type", pe.PatchType()), slog.String("patch.target", pe.Target)) - forged, err = patcher.Apply(ctx, forged, pe) - if err != nil { - return foundryerrors.Wrapf(err, foundryerrors.TypeInternal, "failed to apply patch for target %q", pe.Target) - } - } - - materials = append(materials, forged...) + if err := p.MergeStatusIntoSpec(); err != nil { + return err } - // The moldings resolve each casting in place, so the set that arrived is the - // set the lock records. It is written once every document has forged. - foundry.Logger.InfoContext(ctx, "writing lock file") - if err := foundry.Config.CreateV1Alpha1Lock(ctx, machineries, path); err != nil { + forged, err := p.Forge(ctx, poursWriterOpts.TargetDirectory) + if err != nil { return err } - if len(materials) == 0 { - foundry.Logger.WarnContext(ctx, "castings did not generate any materials for writing") - return nil + for _, pe := range p.Patches() { + patcher, ok := foundry.Patchers[pe.PatchType()] + if !ok { + return foundryerrors.Newf(foundryerrors.TypeUnsupported, "unknown patch type %q", pe.PatchType()) + } + foundry.Logger.InfoContext(ctx, "applying patch", slog.String("patch.type", pe.PatchType()), slog.String("patch.target", pe.Target)) + forged, err = patcher.Apply(ctx, forged, pe) + if err != nil { + return foundryerrors.Wrapf(err, foundryerrors.TypeInternal, "failed to apply patch for target %q", pe.Target) + } } - poursWriter, err := writer.New(foundry.Logger, poursWriterOpts) - if err != nil { - return err + if len(forged) == 0 { + foundry.Logger.WarnContext(ctx, "casting did not generate any materials for writing") + } else { + poursWriter, err := writer.New(foundry.Logger, poursWriterOpts) + if err != nil { + return err + } + + foundry.Logger.InfoContext(ctx, "writing materials", slog.Int("count", len(forged))) + if err := poursWriter.WriteMany(ctx, forged...); err != nil { + return err + } } - foundry.Logger.InfoContext(ctx, "writing materials", slog.Int("count", len(materials))) - return poursWriter.WriteMany(ctx, materials...) + return foundry.Config.CreateOrUpdateV1Alpha1Lock(ctx, machinery, path) } diff --git a/internal/foundry/foundry.go b/internal/foundry/foundry.go index ef960b57..cfc6f0a9 100644 --- a/internal/foundry/foundry.go +++ b/internal/foundry/foundry.go @@ -56,24 +56,11 @@ func New(logger *slog.Logger) (*Foundry, error) { }, nil } -// Plan builds one planner per casting document, in the order the documents were -// resolved. Every verb runs against the same set, so a command that gauges, -// forges and casts plans once. -func (foundry *Foundry) Plan(ctx context.Context, machineries []v1alpha1.Machinery) ([]planner.Planner, error) { - planners := make([]planner.Planner, 0, len(machineries)) - for _, machinery := range machineries { - ctor, ok := foundry.plannerCtors[machinery.Kind()] - if !ok { - return nil, foundryerrors.Newf(foundryerrors.TypeUnsupported, "unsupported casting kind %q", machinery.Kind()) - } - - p, err := ctor(ctx, machinery, foundry.Logger) - if err != nil { - return nil, err - } - - planners = append(planners, p) +func (foundry *Foundry) Plan(ctx context.Context, machinery v1alpha1.Machinery) (planner.Planner, error) { + ctor, ok := foundry.plannerCtors[machinery.Kind()] + if !ok { + return nil, foundryerrors.Newf(foundryerrors.TypeUnsupported, "unsupported casting kind %q", machinery.Kind()) } - return planners, nil + return ctor(ctx, machinery, foundry.Logger) } diff --git a/internal/foundry/gauge.go b/internal/foundry/gauge.go index ac0b3c59..cb75a710 100644 --- a/internal/foundry/gauge.go +++ b/internal/foundry/gauge.go @@ -13,13 +13,13 @@ import ( // Gauge checks the tools the whole casting file needs. Documents that share a // tool gauge it once, so a machine is neither probed nor reported twice. func (foundry *Foundry) Gauge(ctx context.Context, machineries []v1alpha1.Machinery) error { - planners, err := foundry.Plan(ctx, machineries) - if err != nil { - return err - } - toolers := []tooler.Tooler{} - for _, p := range planners { + for _, machinery := range machineries { + p, err := foundry.Plan(ctx, machinery) + if err != nil { + return err + } + toolers = append(toolers, p.Toolers()...) }