mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: surface missing coder_secret requirements on resolve-autostart (#25081)
Adds `dynamicparameters.EvaluateSecretMismatch` as a shared helper on top of the existing renderer, then wires it into the resolve-autostart handler so the UI can surface unsatisfied `coder_secret` requirements in a template alongside parameter mismatch for autostart. The lifecycle executor changes will land in a follow-up that depend on this helper. The UI changes that consume the new `secret_mismatch` field is also a follow-up. Generated with assistance from Coder Agents.
This commit is contained in:
@@ -0,0 +1,100 @@
|
||||
package dynamicparameters
|
||||
|
||||
import (
|
||||
"context"
|
||||
"slices"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/hashicorp/hcl/v2"
|
||||
|
||||
"cdr.dev/slog/v3"
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/coderd/files"
|
||||
"github.com/coder/coder/v2/coderd/util/slice"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
previewtypes "github.com/coder/preview/types"
|
||||
)
|
||||
|
||||
// EvaluateSecretMismatch reports whether the given template version
|
||||
// declares coder_secret requirements that the workspace owner's secrets
|
||||
// do not satisfy. Returns false (no mismatch) when the renderer cannot
|
||||
// authoritatively evaluate the requirements; the reason is logged at the
|
||||
// appropriate level so operators can distinguish a forbidden caller
|
||||
// (expected for template admins) from a genuine renderer or DB failure.
|
||||
// Returns ErrTemplateVersionNotReady when the version's provisioner job
|
||||
// has not yet completed; callers should treat that as "unknown" and
|
||||
// leave SecretMismatch false.
|
||||
func EvaluateSecretMismatch(
|
||||
ctx context.Context,
|
||||
logger slog.Logger,
|
||||
db database.Store,
|
||||
cache files.FileAcquirer,
|
||||
version database.TemplateVersion,
|
||||
ownerID uuid.UUID,
|
||||
buildParams []database.WorkspaceBuildParameter,
|
||||
) (bool, error) {
|
||||
paramValues := slice.ToMapFunc(buildParams, func(p database.WorkspaceBuildParameter) (string, string) {
|
||||
return p.Name, p.Value
|
||||
})
|
||||
renderer, err := Prepare(ctx, db, cache, version.ID,
|
||||
WithTemplateVersion(version),
|
||||
WithLogger(logger))
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer renderer.Close()
|
||||
|
||||
result, diags := renderer.Render(ctx, ownerID, paramValues, IncludeSecretRequirements())
|
||||
|
||||
// Three distinct "unknown" cases. Returning false from any of them
|
||||
// matches the resolve-autostart handler's semantics, but they have
|
||||
// very different operator implications, so we log accordingly. The
|
||||
// renderer already logs its own diagnostics through the same logger,
|
||||
// so we omit them here to avoid duplication.
|
||||
if result.Output == nil {
|
||||
logger.Warn(ctx,
|
||||
"secret requirement evaluation produced no preview output; treating as unknown",
|
||||
slog.F("template_version_id", version.ID),
|
||||
)
|
||||
return false, nil
|
||||
}
|
||||
switch secretValidationBlockerCode(diags) {
|
||||
case DiagCodeOwnerSecretsFetchFailed:
|
||||
logger.Warn(ctx,
|
||||
"failed to fetch owner secrets during requirement evaluation; treating as unknown",
|
||||
slog.F("template_version_id", version.ID),
|
||||
)
|
||||
return false, nil
|
||||
case DiagCodeSecretValidationForbidden:
|
||||
// Expected when a caller without user_secret:read on the owner
|
||||
// hits the renderer, e.g. a template admin viewing another user's
|
||||
// workspace. Debug-level keeps production volume sane while
|
||||
// preserving visibility under trace logging.
|
||||
logger.Debug(ctx,
|
||||
"secret requirement evaluation forbidden for caller; treating as unknown",
|
||||
slog.F("template_version_id", version.ID),
|
||||
)
|
||||
return false, nil
|
||||
}
|
||||
|
||||
return slices.ContainsFunc(result.SecretRequirements,
|
||||
func(s codersdk.SecretRequirementStatus) bool { return !s.Satisfied }), nil
|
||||
}
|
||||
|
||||
// secretValidationBlockerCode returns the first diagnostic code among the
|
||||
// codes that indicate secret-requirement evaluation could not be
|
||||
// performed. Returns the empty string if no such diagnostic is present.
|
||||
//
|
||||
// ExtractDiagnosticExtra walks the wrapped-extra chain so we still
|
||||
// detect our marker when another extra has been chained on top by
|
||||
// preview's SetDiagnosticExtra.
|
||||
func secretValidationBlockerCode(diags hcl.Diagnostics) string {
|
||||
for _, d := range diags {
|
||||
extra := previewtypes.ExtractDiagnosticExtra(d)
|
||||
switch extra.Code {
|
||||
case DiagCodeOwnerSecretsFetchFailed, DiagCodeSecretValidationForbidden:
|
||||
return extra.Code
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package dynamicparameters
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/hashicorp/hcl/v2"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
previewtypes "github.com/coder/preview/types"
|
||||
)
|
||||
|
||||
func TestSecretValidationBlockerCode(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
in hcl.Diagnostics
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "Empty",
|
||||
in: hcl.Diagnostics{},
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "MissingSecretIsNotBlocking",
|
||||
in: hcl.Diagnostics{{
|
||||
Severity: hcl.DiagError,
|
||||
Summary: "Missing required secrets",
|
||||
Extra: previewtypes.DiagnosticExtra{
|
||||
Code: DiagCodeMissingSecret,
|
||||
},
|
||||
}},
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "Forbidden",
|
||||
in: hcl.Diagnostics{{
|
||||
Severity: hcl.DiagWarning,
|
||||
Summary: "Cannot validate secret requirements",
|
||||
Extra: previewtypes.DiagnosticExtra{
|
||||
Code: DiagCodeSecretValidationForbidden,
|
||||
},
|
||||
}},
|
||||
want: DiagCodeSecretValidationForbidden,
|
||||
},
|
||||
{
|
||||
name: "FetchFailed",
|
||||
in: hcl.Diagnostics{{
|
||||
Severity: hcl.DiagError,
|
||||
Summary: "Failed to fetch owner secrets",
|
||||
Extra: previewtypes.DiagnosticExtra{
|
||||
Code: DiagCodeOwnerSecretsFetchFailed,
|
||||
},
|
||||
}},
|
||||
want: DiagCodeOwnerSecretsFetchFailed,
|
||||
},
|
||||
{
|
||||
name: "DiagnosticWithNoExtraIsIgnored",
|
||||
in: hcl.Diagnostics{{
|
||||
Severity: hcl.DiagError,
|
||||
Summary: "Some other error",
|
||||
}},
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "MixedKeepsLookingUntilMatch",
|
||||
in: hcl.Diagnostics{
|
||||
{
|
||||
Severity: hcl.DiagError,
|
||||
Summary: "Missing required secrets",
|
||||
Extra: previewtypes.DiagnosticExtra{
|
||||
Code: DiagCodeMissingSecret,
|
||||
},
|
||||
},
|
||||
{
|
||||
Severity: hcl.DiagError,
|
||||
Summary: "Failed to fetch owner secrets",
|
||||
Extra: previewtypes.DiagnosticExtra{
|
||||
Code: DiagCodeOwnerSecretsFetchFailed,
|
||||
},
|
||||
},
|
||||
},
|
||||
want: DiagCodeOwnerSecretsFetchFailed,
|
||||
},
|
||||
{
|
||||
// SetDiagnosticExtra wraps any pre-existing extra into
|
||||
// previewtypes.DiagnosticExtra.Wrapped. ExtractDiagnosticExtra
|
||||
// walks that chain. A naive type assertion would miss it.
|
||||
name: "WrappedExtraIsDetected",
|
||||
in: func() hcl.Diagnostics {
|
||||
d := &hcl.Diagnostic{
|
||||
Severity: hcl.DiagWarning,
|
||||
Summary: "Cannot validate secret requirements",
|
||||
Extra: "some other extra",
|
||||
}
|
||||
previewtypes.SetDiagnosticExtra(d, previewtypes.DiagnosticExtra{
|
||||
Code: DiagCodeSecretValidationForbidden,
|
||||
})
|
||||
return hcl.Diagnostics{d}
|
||||
}(),
|
||||
want: DiagCodeSecretValidationForbidden,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
require.Equal(t, tc.want, secretValidationBlockerCode(tc.in))
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user