Skip to content

Commit bf17efb

Browse files
authored
Merge pull request #103 from NDDev-OpenNetwork/feat/versioned-consumer-pins-20260907
feat: verify versioned artifacts in consumer pin transactions
2 parents 1679e91 + cfe6281 commit bf17efb

13 files changed

Lines changed: 846 additions & 70 deletions

File tree

core/app/module_consumers.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ import (
1212

1313
type ModuleConsumerPlanOptions struct {
1414
ProjectionOperationOptions
15+
Version string
16+
RuntimeConfig string
1517
ModulePath string
1618
InventoryRoot string
1719
MaxDepth int
@@ -152,6 +154,7 @@ func (services *Services) PlanModuleConsumerUpdates(
152154
envelope := services.PlanModuleUpdatePin(ctx, path, ModulePinOptions{
153155
ProjectionOperationOptions: options.ProjectionOperationOptions,
154156
ModulePath: moduleInfo.WorktreeRoot, GitmodulesName: name,
157+
Version: options.Version, RuntimeConfig: options.RuntimeConfig,
155158
})
156159
result := ModuleConsumerSubplan{
157160
ConsumerID: consumerID, Mode: mode, Path: path,

core/app/module_pin.go

Lines changed: 82 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package app
22

33
import (
44
"context"
5+
"encoding/json"
56
"errors"
67
"path/filepath"
78
"strings"
@@ -22,18 +23,21 @@ type ModulePinOptions struct {
2223
ProjectionOperationOptions
2324
ModulePath string
2425
GitmodulesName string
26+
Version string
27+
RuntimeConfig string
2528
}
2629

2730
type ModulePinAssessment struct {
28-
ConsumerID string `json:"consumer_id"`
29-
ModuleID string `json:"module_id"`
30-
ConsumerRoot string `json:"consumer_root"`
31-
ModuleRoot string `json:"module_root"`
32-
GitmodulesName string `json:"gitmodules_name"`
33-
GitlinkPath string `json:"gitlink_path"`
34-
ExpectedOldOID string `json:"expected_old_oid"`
35-
TargetOID string `json:"target_oid"`
36-
TargetRef string `json:"target_ref"`
31+
ConsumerID string `json:"consumer_id"`
32+
ModuleID string `json:"module_id"`
33+
ConsumerRoot string `json:"consumer_root"`
34+
ModuleRoot string `json:"module_root"`
35+
GitmodulesName string `json:"gitmodules_name"`
36+
GitlinkPath string `json:"gitlink_path"`
37+
ExpectedOldOID string `json:"expected_old_oid"`
38+
TargetOID string `json:"target_oid"`
39+
TargetRef string `json:"target_ref"`
40+
Artifact *ModulePinArtifact `json:"artifact,omitempty"`
3741
}
3842

3943
type ModulePinPlanData struct {
@@ -48,18 +52,20 @@ type modulePinContext struct {
4852
}
4953

5054
type modulePinObserver struct {
51-
services *Services
52-
consumer string
53-
module string
54-
name string
55+
services *Services
56+
consumer string
57+
module string
58+
name string
59+
version string
60+
runtimeConfig string
5561
}
5662

5763
func (observer modulePinObserver) Observe(
5864
ctx context.Context,
5965
repositoryID string,
6066
) (operations.Observation, error) {
6167
current, findings := observer.services.modulePinContext(
62-
ctx, observer.consumer, observer.module, observer.name,
68+
ctx, observer.consumer, observer.module, observer.name, observer.version, observer.runtimeConfig,
6369
)
6470
if len(findings) != 0 || current.assessment.ConsumerID != repositoryID {
6571
return operations.Observation{}, errors.New("module pin precondition is no longer proven")
@@ -75,7 +81,7 @@ func (services *Services) PlanModuleUpdatePin(
7581
if finding := validateLocalOperationIdentity(options.ProjectionOperationOptions); finding != nil {
7682
return domain.NewEnvelope("gds module update-pin plan", domain.ExitInput, nil, *finding)
7783
}
78-
current, findings := services.modulePinContext(ctx, path, options.ModulePath, options.GitmodulesName)
84+
current, findings := services.modulePinContext(ctx, path, options.ModulePath, options.GitmodulesName, options.Version, options.RuntimeConfig)
7985
if len(findings) != 0 {
8086
return domain.NewEnvelope(
8187
"gds module update-pin plan", classifyFindings(findings), nil, findings...,
@@ -111,15 +117,7 @@ func (services *Services) PlanModuleUpdatePin(
111117
// this repository: provider lifecycle, rulesets, releases and anchors.
112118
Action: gitops.UpdateGitlinkAction, RequiresApproval: false,
113119
Compensation: operations.Compensation{Mode: "explicit-plan", Action: gitops.UpdateGitlinkAction},
114-
Parameters: map[string]any{"gitlink_pin": map[string]any{
115-
"consumer_root": current.assessment.ConsumerRoot,
116-
"module_root": current.assessment.ModuleRoot,
117-
"module_id": current.assessment.ModuleID,
118-
"gitmodules_name": current.assessment.GitmodulesName,
119-
"expected_old_oid": current.assessment.ExpectedOldOID,
120-
"target_oid": current.assessment.TargetOID,
121-
"target_ref": current.assessment.TargetRef,
122-
}},
120+
Parameters: map[string]any{"gitlink_pin": modulePinParameters(current.assessment)},
123121
}},
124122
ApprovalClass: "update-module-gitlink-pin",
125123
})
@@ -128,7 +126,7 @@ func (services *Services) PlanModuleUpdatePin(
128126
}
129127
engine := operations.NewDefaultEngine(
130128
store, services.Schemas,
131-
modulePinObserver{services: services, consumer: current.assessment.ConsumerRoot, module: current.assessment.ModuleRoot, name: current.assessment.GitmodulesName},
129+
modulePinObserver{services: services, consumer: current.assessment.ConsumerRoot, module: current.assessment.ModuleRoot, name: current.assessment.GitmodulesName, version: options.Version, runtimeConfig: options.RuntimeConfig},
132130
nil, options.DeviceID, options.SessionID,
133131
)
134132
engine.Now = services.Now
@@ -173,8 +171,8 @@ func (services *Services) ApplyModuleUpdatePin(
173171
}
174172
engine := operations.NewDefaultEngine(
175173
store, services.Schemas,
176-
modulePinObserver{services: services, consumer: assessment.ConsumerRoot, module: assessment.ModuleRoot, name: assessment.GitmodulesName},
177-
map[string]operations.ActionHandler{gitops.UpdateGitlinkAction: handler},
174+
modulePinObserver{services: services, consumer: assessment.ConsumerRoot, module: assessment.ModuleRoot, name: assessment.GitmodulesName, version: modulePinVersion(assessment), runtimeConfig: options.RuntimeConfig},
175+
map[string]operations.ActionHandler{gitops.UpdateGitlinkAction: services.modulePinHandler(handler, assessment, plan, options.RuntimeConfig)},
178176
options.DeviceID, options.SessionID,
179177
)
180178
engine.Now = services.Now
@@ -225,7 +223,7 @@ func (services *Services) VerifyModuleUpdatePin(
225223
}
226224
engine := operations.NewDefaultEngine(
227225
store, services.Schemas, modulePinObserver{},
228-
map[string]operations.ActionHandler{gitops.UpdateGitlinkAction: handler},
226+
map[string]operations.ActionHandler{gitops.UpdateGitlinkAction: services.modulePinHandler(handler, assessment, plan, options.RuntimeConfig)},
229227
options.DeviceID, options.SessionID,
230228
)
231229
engine.Now = services.Now
@@ -247,6 +245,8 @@ func (services *Services) modulePinContext(
247245
consumerPath string,
248246
modulePath string,
249247
gitmodulesName string,
248+
version string,
249+
runtimeConfig string,
250250
) (modulePinContext, []domain.Finding) {
251251
if strings.TrimSpace(modulePath) == "" || strings.TrimSpace(gitmodulesName) == "" {
252252
return modulePinContext{}, []domain.Finding{modulePinFinding(
@@ -306,37 +306,39 @@ func (services *Services) modulePinContext(
306306
"GDS_MODULE_PIN_IDENTITY_MISMATCH", "Selected module boundary does not match the typed consumer relationship.",
307307
)}
308308
}
309-
if moduleAnchor.Module.PinPolicy != "default-branch-commit" {
310-
return modulePinContext{}, []domain.Finding{modulePinFinding(
311-
"GDS_MODULE_PIN_RELEASE_REQUIRED", "This module pin policy requires a verified versioned release before consumer update.",
312-
)}
309+
if moduleAnchor.Module.PinPolicy != "default-branch-commit" && moduleAnchor.Module.PinPolicy != "version-tag" {
310+
return modulePinContext{}, []domain.Finding{modulePinFinding("GDS_MODULE_PIN_RELEASE_REQUIRED", "Module pin policy requires an unsupported publication provider.")}
311+
}
312+
if (moduleAnchor.Module.PinPolicy == "version-tag") != (strings.TrimSpace(version) != "") {
313+
return modulePinContext{}, []domain.Finding{modulePinFinding("GDS_MODULE_PIN_VERSION_REQUIRED", "Select --version exactly when the module pin policy is version-tag.")}
313314
}
314315
moduleInfo, err := services.Git.RepositoryInfo(ctx, modulePath)
315316
if err != nil {
316317
return modulePinContext{}, []domain.Finding{modulePinFinding("GDS_MODULE_PIN_SOURCE_NOT_PROVEN", err.Error())}
317318
}
318319
moduleRoot := moduleInfo.WorktreeRoot
319320
moduleStatus, err := services.Git.InspectStatus(ctx, moduleRoot)
320-
if err != nil || moduleStatus.Head.Mode != "branch" ||
321-
moduleStatus.Branch.Name != moduleAnchor.Git.DefaultBranch || !checkoutStatusIsClean(moduleStatus) {
322-
return modulePinContext{}, []domain.Finding{modulePinFinding(
323-
"GDS_MODULE_PIN_SOURCE_STATE_UNSAFE", "Module source must be clean on its default branch.",
324-
)}
321+
if err != nil || !checkoutStatusIsClean(moduleStatus) || moduleStatus.Head.OID == "" {
322+
return modulePinContext{}, []domain.Finding{modulePinFinding("GDS_MODULE_PIN_SOURCE_STATE_UNSAFE", "Module source must be clean at the selected commit.")}
325323
}
326-
// The module's origin is observed, never written. `LocalPushSupported` used
327-
// to guard this line, which asks whether the module's remote accepts a push
328-
// from this device -- a question this operation never needs, since the only
329-
// mutation is a gitlink rewrite in the consumer. It refuses every remote that
330-
// is not a local path, so on a real estate it refused every module, and the
331-
// pin could not advance for that reason alone. `ObserveRemoteBranchOptional`
332-
// is an `ls-remote`, and proving the target commit is published is exactly
333-
// what this step is for.
334324
targetRef := "refs/heads/" + moduleAnchor.Git.DefaultBranch
335-
targetOID, found, err := services.GitMutations.ObserveRemoteBranchOptional(ctx, moduleRoot, "origin", targetRef)
336-
if err != nil || !found || targetOID != moduleStatus.Head.OID {
337-
return modulePinContext{}, []domain.Finding{modulePinFinding(
338-
"GDS_MODULE_PIN_TARGET_NOT_PUBLISHED", "Module default commit is not exactly published on its configured origin.",
339-
)}
325+
targetOID := ""
326+
var artifact *ModulePinArtifact
327+
if moduleAnchor.Module.PinPolicy == "version-tag" {
328+
artifact, err = services.observeModulePinArtifact(ctx, moduleRoot, version, runtimeConfig)
329+
if err != nil {
330+
return modulePinContext{}, []domain.Finding{modulePinArtifactFinding(err)}
331+
}
332+
targetRef, targetOID = artifact.Tag.TagRef, artifact.Tag.CommitOID
333+
} else {
334+
if moduleStatus.Head.Mode != "branch" || moduleStatus.Branch.Name != moduleAnchor.Git.DefaultBranch {
335+
return modulePinContext{}, []domain.Finding{modulePinFinding("GDS_MODULE_PIN_SOURCE_STATE_UNSAFE", "Module source must be clean on its default branch.")}
336+
}
337+
var found bool
338+
targetOID, found, err = services.GitMutations.ObserveRemoteBranchOptional(ctx, moduleRoot, "origin", targetRef)
339+
if err != nil || !found || targetOID != moduleStatus.Head.OID {
340+
return modulePinContext{}, []domain.Finding{modulePinFinding("GDS_MODULE_PIN_TARGET_NOT_PUBLISHED", "Module default commit is not exactly published on its configured origin.")}
341+
}
340342
}
341343
// Resolve cheap eligibility and policy failures before materializing a
342344
// throwaway checkout or invoking any module command. Rejected pins must
@@ -401,8 +403,9 @@ func (services *Services) modulePinContext(
401403
// Verification joins the fingerprint so the plan is bound to the evidence
402404
// that justified it. A plan approved while a lane passed must not stay
403405
// applicable after that lane stops passing.
404-
Verification string `json:"verification"`
405-
}{consumerStatus, *submodule, moduleAnchor.Repository.ID, moduleStatus.Head.OID, moduleManifestDigest, targetRef, targetOID, verificationDigest})
406+
Verification string `json:"verification"`
407+
Artifact *ModulePinArtifact `json:"artifact,omitempty"`
408+
}{consumerStatus, *submodule, moduleAnchor.Repository.ID, moduleStatus.Head.OID, moduleManifestDigest, targetRef, targetOID, verificationDigest, artifact})
406409
if err != nil {
407410
return modulePinContext{}, []domain.Finding{modulePinFinding("GDS_MODULE_PIN_FINGERPRINT_FAILED", err.Error())}
408411
}
@@ -411,7 +414,7 @@ func (services *Services) modulePinContext(
411414
ConsumerID: consumer.Repository.ID, ModuleID: moduleAnchor.Repository.ID,
412415
ConsumerRoot: consumerInfo.WorktreeRoot, ModuleRoot: moduleRoot,
413416
GitmodulesName: gitmodulesName, GitlinkPath: submodule.Path,
414-
ExpectedOldOID: submodule.GitlinkOID, TargetOID: targetOID, TargetRef: targetRef,
417+
ExpectedOldOID: submodule.GitlinkOID, TargetOID: targetOID, TargetRef: targetRef, Artifact: artifact,
415418
},
416419
observation: operations.Observation{
417420
RepositoryID: consumer.Repository.ID, HeadOID: consumerStatus.Head.OID,
@@ -468,6 +471,19 @@ func loadModulePinPlan(
468471
assessment.ExpectedOldOID, _ = raw["expected_old_oid"].(string)
469472
assessment.TargetOID, _ = raw["target_oid"].(string)
470473
assessment.TargetRef, _ = raw["target_ref"].(string)
474+
if raw["artifact"] != nil {
475+
encoded, marshalErr := json.Marshal(raw["artifact"])
476+
if marshalErr != nil || json.Unmarshal(encoded, &assessment.Artifact) != nil ||
477+
assessment.Artifact == nil || assessment.Artifact.Version == "" ||
478+
assessment.Artifact.Tag.TagRef != assessment.TargetRef ||
479+
assessment.Artifact.Tag.CommitOID != assessment.TargetOID ||
480+
assessment.Artifact.Tag.TagOID == "" || assessment.Artifact.ManifestDigest == "" {
481+
return operations.Plan{}, ModulePinAssessment{}, errors.New("module artifact parameters are invalid")
482+
}
483+
}
484+
if strings.HasPrefix(assessment.TargetRef, "refs/tags/") && assessment.Artifact == nil {
485+
return operations.Plan{}, ModulePinAssessment{}, errors.New("version pin plan lacks artifact evidence")
486+
}
471487
if assessment.ConsumerRoot == "" || assessment.ModuleRoot == "" || assessment.ModuleID == "" ||
472488
assessment.GitmodulesName == "" || assessment.ExpectedOldOID == "" ||
473489
assessment.TargetOID == "" || assessment.TargetRef == "" {
@@ -535,3 +551,16 @@ func pinWorktreeStateIsEligible(submodule gitprovider.Submodule, targetOID strin
535551
return false
536552
}
537553
}
554+
555+
func modulePinParameters(assessment ModulePinAssessment) map[string]any {
556+
result := map[string]any{
557+
"consumer_root": assessment.ConsumerRoot, "module_root": assessment.ModuleRoot,
558+
"module_id": assessment.ModuleID, "gitmodules_name": assessment.GitmodulesName,
559+
"expected_old_oid": assessment.ExpectedOldOID, "target_oid": assessment.TargetOID,
560+
"target_ref": assessment.TargetRef,
561+
}
562+
if assessment.Artifact != nil {
563+
result["artifact"] = assessment.Artifact
564+
}
565+
return result
566+
}

0 commit comments

Comments
 (0)