Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 36 additions & 13 deletions boatstack/attach.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ type AttachResult struct {
ControlRoot string `json:"control_root,omitempty"`
WorktreeID string `json:"worktree_id,omitempty"`
ConfigSHA256 string `json:"config_sha256,omitempty"`
ConfigAuthority string `json:"config_authority,omitempty"`
Reason string `json:"reason"`
FeatureMigrations []DetachedFeatureMigration `json:"feature_migrations,omitempty"`
}
Expand Down Expand Up @@ -114,6 +115,13 @@ func AttachDetached(opts AttachOptions) (AttachResult, error) {
return blockedAttach("Boatstack could not load the detached project configuration: " + err.Error()), nil
}
configSHA256 := SHA256Bytes(rawConfig)
configAuthority := ConfigAuthorityExternalSnapshot
if strings.TrimSpace(opts.ConfigPath) == "" {
configAuthority = ConfigAuthoritySynthesized
if fileExists(filepath.Join(root, sourceConfigName)) {
configAuthority = ConfigAuthorityRepository
}
}
imports, migrationResults, migrationErr := planDetachedFeatureImports(root, ctx)
if migrationErr != nil {
result := blockedAttach("Boatstack refused detached feature migration: " + migrationErr.Error())
Expand Down Expand Up @@ -156,6 +164,7 @@ func AttachDetached(opts AttachOptions) (AttachResult, error) {
InitialCommit: identity.InitialCommit,
NormalizedOrigin: identity.NormalizedOrigin,
ConfigSHA256: configSHA256,
ConfigAuthority: configAuthority,
CreatedByVersion: Version,
CreatedAt: nowRFC3339(),
}
Expand Down Expand Up @@ -201,6 +210,7 @@ func AttachDetached(opts AttachOptions) (AttachResult, error) {
ControlRoot: ctx.controlRoot,
WorktreeID: identity.WorktreeID,
ConfigSHA256: configSHA256,
ConfigAuthority: configAuthority,
FeatureMigrations: migrationResults,
Reason: "Attached Boatstack in detached mode. The repository was not modified; all controller state lives under the external control root.",
}, nil
Expand Down Expand Up @@ -269,16 +279,22 @@ func DetachDetached(opts DetachOptions) (DetachResult, error) {
// DetachedStatusResult reports whether a repository is attached in detached mode
// and whether its binding verifies.
type DetachedStatusResult struct {
SchemaVersion int `json:"schema_version"`
Attached bool `json:"attached"`
Verified bool `json:"verified"`
Mode string `json:"mode"`
RepoID string `json:"repo_id,omitempty"`
RepoRoot string `json:"repo_root,omitempty"`
ControlRoot string `json:"control_root,omitempty"`
WorktreeID string `json:"worktree_id,omitempty"`
ConfigSHA256 string `json:"config_sha256,omitempty"`
Reason string `json:"reason"`
SchemaVersion int `json:"schema_version"`
Attached bool `json:"attached"`
Verified bool `json:"verified"`
Mode string `json:"mode"`
RepoID string `json:"repo_id,omitempty"`
RepoRoot string `json:"repo_root,omitempty"`
ControlRoot string `json:"control_root,omitempty"`
WorktreeID string `json:"worktree_id,omitempty"`
ConfigSHA256 string `json:"config_sha256,omitempty"`
ConfigAuthority string `json:"config_authority,omitempty"`
ConfigRelation string `json:"config_relation,omitempty"`
RepositoryConfigSHA256 string `json:"repository_config_sha256,omitempty"`
ControllerConfigSHA256 string `json:"controller_config_sha256,omitempty"`
AffectedWorktrees []string `json:"affected_worktrees,omitempty"`
NextOperation string `json:"next_operation,omitempty"`
Reason string `json:"reason"`
}

// DetachedStatus reports the detached attachment state for a repository. It is
Expand Down Expand Up @@ -309,14 +325,21 @@ func DetachedStatus(repoPath string) (DetachedStatusResult, error) {
return DetachedStatusResult{
SchemaVersion: detachedSchemaVersion, Attached: true, Verified: false, Mode: string(SupervisionDetached),
RepoID: ctx.RepoID, RepoRoot: root, ControlRoot: ctx.controlRoot, WorktreeID: ctx.WorktreeID,
ConfigSHA256: configSHA256, Reason: verifyErr.Error(),
ConfigSHA256: configSHA256, ControllerConfigSHA256: configSHA256, Reason: verifyErr.Error(),
}, nil
}
topology, topologyErr := ResolveConfigurationTopology(root)
if topologyErr != nil {
return DetachedStatusResult{SchemaVersion: detachedSchemaVersion, Attached: true, Verified: false, Mode: string(SupervisionDetached), RepoID: ctx.RepoID, RepoRoot: root, ControlRoot: ctx.controlRoot, WorktreeID: ctx.WorktreeID, Reason: topologyErr.Error()}, nil
}
return DetachedStatusResult{
SchemaVersion: detachedSchemaVersion, Attached: true, Verified: true, Mode: string(SupervisionDetached),
RepoID: ctx.RepoID, RepoRoot: ctx.RepoRoot, ControlRoot: ctx.controlRoot, WorktreeID: ctx.WorktreeID,
ConfigSHA256: bindingConfigSHA256(ctx),
Reason: "This repository is attached in detached mode and its binding verifies.",
ConfigSHA256: topology.ControllerConfigSHA256, ConfigAuthority: topology.Authority,
ConfigRelation: topology.Relation, RepositoryConfigSHA256: topology.RepositoryConfigSHA256,
ControllerConfigSHA256: topology.ControllerConfigSHA256, AffectedWorktrees: topology.AffectedWorktrees,
NextOperation: topology.NextOperation,
Reason: "This repository is attached in detached mode and its binding verifies.",
}, nil
}

Expand Down
2 changes: 1 addition & 1 deletion boatstack/cmd/boatstack-helper/command_trace.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ type commandTracePolicy struct {
// to the enforcement path; every other dispatch is recorded once by run().
var commandTracePolicies = map[string]commandTracePolicy{
"attach": {Category: "supervision"}, "detach": {Category: "supervision"},
"detached-status": {Category: "supervision"}, "context": {Category: "supervision"},
"detached-status": {Category: "supervision"}, "config-rebind": {Category: "supervision"}, "context": {Category: "supervision"},
"activate": {Category: "supervision"}, "deactivate": {Category: "supervision"},
"init": {Category: "installation"}, "update": {Category: "installation"},
"check-update": {Category: "installation"}, "repair-status": {Category: "installation"},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ var nonDeliveryVerbs = map[string]bool{
"attach": true,
"detach": true,
"detached-status": true,
"config-rebind": true,
"context": true,
"activate": true,
"deactivate": true,
Expand Down
93 changes: 48 additions & 45 deletions boatstack/cmd/boatstack-helper/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,42 @@ func detachedStatusCommand(arguments []string) int {
return emitJSON(result)
}

func configRebindCommand(arguments []string) int {
flags := flag.NewFlagSet("config-rebind", flag.ContinueOnError)
repo := flags.String("repo", ".", "attached repository whose configuration authority should be rebound")
source := flags.String("source", "", "authoritative source: repository, controller, or file")
configPath := flags.String("config", "", "external configuration path used with --source file")
apply := flags.Bool("apply", false, "apply the fingerprinted preview")
expectedFingerprint := flags.String("expected-fingerprint", "", "exact preview fingerprint required by --apply")
jsonOutput := flags.Bool("json", false, "render the result as JSON")
if err := flags.Parse(arguments); err != nil {
return 2
}
result, err := boatstack.ConfigRebind(boatstack.ConfigRebindOptions{
Repo: *repo, Source: *source, ConfigPath: *configPath,
Apply: *apply, ExpectedFingerprint: *expectedFingerprint,
})
if err != nil {
return fail(err)
}
value, err := json.Marshal(result)
if err != nil {
return fail(err)
}
if *jsonOutput {
fmt.Println(string(value))
} else {
fmt.Println(result.Reason)
if result.NextOperation != "" {
fmt.Println("NEXT=" + result.NextOperation)
}
}
if result.VerificationStatus != "VERIFIED" {
return 1
}
return 0
}

func activateCommand(arguments []string) int {
flags := flag.NewFlagSet("activate", flag.ContinueOnError)
repo := flags.String("repo", ".", "attached repository to activate")
Expand Down Expand Up @@ -392,6 +428,9 @@ func exportCommand(arguments []string) int {
if *repo == "" || *configPath == "" || (*write && *check) {
return fail(fmt.Errorf("export requires --repo and --config; --write and --check are mutually exclusive"))
}
if err := boatstack.ValidateConfigurationExport(*repo, *configPath, *write); err != nil {
return fail(err)
}
config, raw, err := boatstack.LoadConfig(*configPath)
if err != nil {
return fail(err)
Expand Down Expand Up @@ -1337,64 +1376,26 @@ func checkSafetyCommand(arguments []string) int {
return 0
}

type MigrateConfigReport struct {
Status string `json:"status"`
Message string `json:"message,omitempty"`
FromVersion int `json:"from_version"`
ToVersion int `json:"to_version"`
Changed bool `json:"changed"`
}

func migrateConfigCommand(arguments []string) int {
flags := flag.NewFlagSet("migrate-config", flag.ContinueOnError)
repo := flags.String("repo", ".", "repository whose configuration should be migrated")
target := flags.String("target", "", "configuration projection: repository or controller; required for hybrid installations")
check := flags.Bool("check", false, "dry-run check mode")
if err := flags.Parse(arguments); err != nil {
return 2
}
configPath := boatstack.WorkspaceFor(*repo).SourceConfigPath()
raw, err := os.ReadFile(configPath)
report, err := boatstack.MigrateManagedConfiguration(*repo, *target, *check)
if err != nil {
report := MigrateConfigReport{
Status: "FAIL",
Message: fmt.Sprintf("failed to read config: %v", err),
}
value, _ := json.Marshal(report)
fmt.Print(string(value))
return 1
}
upgraded, fromVer, toVer, changed, err := boatstack.MigrateConfigBytes(raw)
if err != nil {
report := MigrateConfigReport{
Status: "FAIL",
Message: fmt.Sprintf("migration failed: %v", err),
}
value, _ := json.Marshal(report)
fmt.Print(string(value))
return 1
}
if changed && !*check {
if err := os.WriteFile(configPath, upgraded, 0o644); err != nil {
report := MigrateConfigReport{
Status: "FAIL",
Message: fmt.Sprintf("failed to write migrated config: %v", err),
}
value, _ := json.Marshal(report)
fmt.Print(string(value))
return 1
}
}
report := MigrateConfigReport{
Status: "PASS",
FromVersion: fromVer,
ToVersion: toVer,
Changed: changed,
return fail(err)
}
value, err := json.Marshal(report)
if err != nil {
return fail(err)
}
fmt.Print(string(value))
if report.Status != "PASS" {
return 1
}
return 0
}

Expand Down Expand Up @@ -1613,7 +1614,7 @@ func workspaceSyncCommand(arguments []string) int {

func run() (result int) {
if len(os.Args) < 2 {
fmt.Fprintln(os.Stderr, "usage: boatstack-helper <attach|detach|detached-status|context|activate|deactivate|init|update|check-update|repair-status|operation-status|prepare-update-pr|publish-update-pr|release-classify|next-patch|export|check-source-plan|planning-write|check-plan|record-approval|record-autonomy|activate-plan|delivery-status|next-status|recovery-status|repair-state|mutation-status|undo|run-preflight|authority-context|record-change|record-journey-results|ignore-delivery|record-delivery-gate|record-pr-visual-evidence|review-pr-visual-evidence|capture-evidence|provision-capability|capability-register|record-pr-visual-publication|attach-evidence|check-safety|migrate-config|safety-hook|ambient-safety-hook|bootstrap-safety-hook|hydrate-runtime|activate-worktree-runtime|diagnose-hook|render-denial|pr-context|check-pr|publish-pr|workspace-cut|workspace-cleanup|workspace-reap|workspace-status|workspace-sync|flow|retro|insight|doctor|version>")
fmt.Fprintln(os.Stderr, "usage: boatstack-helper <attach|detach|detached-status|config-rebind|context|activate|deactivate|init|update|check-update|repair-status|operation-status|prepare-update-pr|publish-update-pr|release-classify|next-patch|export|check-source-plan|planning-write|check-plan|record-approval|record-autonomy|activate-plan|delivery-status|next-status|recovery-status|repair-state|mutation-status|undo|run-preflight|authority-context|record-change|record-journey-results|ignore-delivery|record-delivery-gate|record-pr-visual-evidence|review-pr-visual-evidence|capture-evidence|provision-capability|capability-register|record-pr-visual-publication|attach-evidence|check-safety|migrate-config|safety-hook|ambient-safety-hook|bootstrap-safety-hook|hydrate-runtime|activate-worktree-runtime|diagnose-hook|render-denial|pr-context|check-pr|publish-pr|workspace-cut|workspace-cleanup|workspace-reap|workspace-status|workspace-sync|flow|retro|insight|doctor|version>")
return 2
}
if complete := commandTraceCompletion(os.Args[1], os.Args[2:]); complete != nil {
Expand All @@ -1626,6 +1627,8 @@ func run() (result int) {
return detachCommand(os.Args[2:])
case "detached-status":
return detachedStatusCommand(os.Args[2:])
case "config-rebind":
return configRebindCommand(os.Args[2:])
case "context":
return contextCommand(os.Args[2:])
case "activate":
Expand Down
Loading