add feature flag for resource causality

since the causality query is so expensive, it makes sense to give
cluster admins the ability to disable the endpoints and page view.

this is the first time we introduced the idea of feature flags to the
frontend. my solution is to include the current state of the feature
flags in the `/api/v1/info` endpoint, this can then be parsed by the
frontend and stored in `session` making it available to any elm code
that might require it.

if resource causality is disabled (default), the `view all` buttons in
the expended resource version will not show up and trying to navigate to
the causality pages directly via url will result in a standard 404 not
found page.

another change is the stauts code for the causality endpoint will be 403
Forbidden if causality is disabled, and 422 UnprocessableEntity if the
graph is too large.

Signed-off-by: Bohan Chen <bochen@pivotal.io>
This commit is contained in:
Bohan Chen 2021-06-29 16:09:12 -04:00
parent 5b9c07e892
commit 7eb35f23bf
12 changed files with 113 additions and 54 deletions

View File

@ -15,6 +15,15 @@ func (s *Server) Info(w http.ResponseWriter, r *http.Request) {
WorkerVersion: s.workerVersion,
ExternalURL: s.externalURL,
ClusterName: s.clusterName,
FeatureFlags: map[string]bool{
"global_resources": atc.EnableGlobalResources,
"redact_secrets": atc.EnableRedactSecrets,
"build_rerun": atc.EnableBuildRerunWhenWorkerDisappears,
"across_step": atc.EnableAcrossStep,
"pipeline_instances": atc.EnablePipelineInstances,
"cache_streamed_volumes": atc.EnableCacheStreamedVolumes,
"resource_causality": atc.EnableResourceCausality,
},
})
if err != nil {
logger.Error("failed-to-encode-info", err)

View File

@ -8,6 +8,7 @@ import (
"code.cloudfoundry.org/lager"
"github.com/concourse/concourse/atc"
"github.com/concourse/concourse/atc/db"
)
@ -23,6 +24,12 @@ func (s *Server) getResourceCausality(direction db.CausalityDirection, pipeline
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
logger := s.logger.Session(fmt.Sprintf("%v-causality", direction))
if !atc.EnableResourceCausality {
logger.Info("causality-disabled")
w.WriteHeader(http.StatusForbidden)
return
}
versionIDString := r.FormValue(":resource_config_version_id")
resourceName := r.FormValue(":resource_name")
versionID, _ := strconv.Atoi(versionIDString)
@ -44,7 +51,7 @@ func (s *Server) getResourceCausality(direction db.CausalityDirection, pipeline
if err != nil {
if err == db.ErrTooManyBuilds || err == db.ErrTooManyResourceVersions {
logger.Error("too-many-nodes", err, lager.Data{"resource-name": resourceName, "resource-config-version": versionID})
w.WriteHeader(http.StatusForbidden)
w.WriteHeader(http.StatusUnprocessableEntity)
return
} else {
logger.Error("failed-to-fetch", err, lager.Data{"resource-name": resourceName, "resource-config-version": versionID})

View File

@ -251,6 +251,7 @@ type RunCommand struct {
EnablePipelineInstances bool `long:"enable-pipeline-instances" description:"Enable pipeline instances"`
EnableP2PVolumeStreaming bool `long:"enable-p2p-volume-streaming" description:"Enable P2P volume streaming. NOTE: All workers must be on the same LAN network"`
EnableCacheStreamedVolumes bool `long:"enable-cache-streamed-volumes" description:"When enabled, streamed resource volumes will be cached on the destination worker."`
EnableResourceCausality bool `long:"enable-resource-causality" description:"Enable the resource causality page. Computing causality can be expensive for the database. "`
} `group:"Feature Flags"`
BaseResourceTypeDefaults flag.File `long:"base-resource-type-defaults" description:"Base resource type defaults"`
@ -522,6 +523,7 @@ func (cmd *RunCommand) Runner(positionalArguments []string) (ifrit.Runner, error
atc.EnableAcrossStep = cmd.FeatureFlags.EnableAcrossStep
atc.EnablePipelineInstances = cmd.FeatureFlags.EnablePipelineInstances
atc.EnableCacheStreamedVolumes = cmd.FeatureFlags.EnableCacheStreamedVolumes
atc.EnableResourceCausality = cmd.FeatureFlags.EnableResourceCausality
if cmd.BaseResourceTypeDefaults.Path() != "" {
content, err := ioutil.ReadFile(cmd.BaseResourceTypeDefaults.Path())

View File

@ -7,4 +7,5 @@ var (
EnableAcrossStep bool
EnablePipelineInstances bool
EnableCacheStreamedVolumes bool
EnableResourceCausality bool
)

View File

@ -1,8 +1,9 @@
package atc
type Info struct {
Version string `json:"version"`
WorkerVersion string `json:"worker_version"`
ExternalURL string `json:"external_url,omitempty"`
ClusterName string `json:"cluster_name,omitempty"`
Version string `json:"version"`
WorkerVersion string `json:"worker_version"`
FeatureFlags map[string]bool `json:"feature_flags"`
ExternalURL string `json:"external_url,omitempty"`
ClusterName string `json:"cluster_name,omitempty"`
}

View File

@ -36,6 +36,7 @@ services:
CONCOURSE_ENABLE_PIPELINE_INSTANCES: "true"
CONCOURSE_ENABLE_ACROSS_STEP: "true"
CONCOURSE_ENABLE_CACHE_STREAMED_VOLUMES: "true"
CONCOURSE_ENABLE_RESOURCE_CAUSALITY: "true"
worker:
build: .

View File

@ -73,6 +73,9 @@ init flags url =
, hovered = HoverState.NoHover
, clusterName = ""
, version = ""
, featureFlags =
{ resourceCausality = False
}
, turbulenceImgSrc = flags.turbulenceImgSrc
, notFoundImgSrc = flags.notFoundImgSrc
, csrfToken = flags.csrfToken
@ -205,13 +208,13 @@ handleCallback callback model =
in
subpageHandleCallback callback ( { model | session = newSession }, [] )
ClusterInfoFetched (Ok { clusterName, version }) ->
ClusterInfoFetched (Ok { clusterName, version, featureFlags }) ->
let
session =
model.session
newSession =
{ session | clusterName = clusterName, version = version }
{ session | clusterName = clusterName, version = version, featureFlags = featureFlags }
in
subpageHandleCallback callback ( { model | session = newSession }, [] )
@ -263,7 +266,7 @@ subpageHandleCallback callback ( model, effects ) =
( subModel, newEffects ) =
( model.subModel, effects )
|> SubPage.handleCallback callback model.session
|> SubPage.handleNotFound model.session.notFoundImgSrc model.session.route
|> SubPage.handleNotFound model.session
in
( { model | subModel = subModel }, newEffects )
@ -301,7 +304,7 @@ update msg model =
( subModel, subEffects ) =
( model.subModel, [] )
|> SubPage.update model.session m
|> SubPage.handleNotFound model.session.notFoundImgSrc model.session.route
|> SubPage.handleNotFound model.session
( session, sessionEffects ) =
SideBar.update m model.session
@ -323,7 +326,7 @@ handleDelivery delivery model =
( newSubmodel, subPageEffects ) =
( model.subModel, [] )
|> SubPage.handleDelivery model.session delivery
|> SubPage.handleNotFound model.session.notFoundImgSrc model.session.route
|> SubPage.handleNotFound model.session
( newModel, applicationEffects ) =
handleDeliveryForApplication

View File

@ -11,6 +11,7 @@ type alias Session =
{ userState : UserState
, clusterName : String
, version : String
, featureFlags : Concourse.FeatureFlags
, turbulenceImgSrc : String
, notFoundImgSrc : String
, csrfToken : Concourse.CSRFToken

View File

@ -148,9 +148,12 @@ handleCallback callback ( model, effects ) =
if status.code == 401 then
( model, effects ++ [ RedirectToLogin ] )
else if status.code == 403 then
else if status.code == 422 then
( { model | pageStatus = Err TooManyNodes }, effects )
else if status.code == 403 then
( { model | pageStatus = Err NotFound }, effects )
else if status.code == 404 then
( { model | pageStatus = Err NotFound }, effects )
@ -213,17 +216,21 @@ update msg ( model, effects ) =
( model, effects )
getUpdateMessage : Model -> UpdateMsg
getUpdateMessage model =
case model.pageStatus of
Err NotFound ->
UpdateMsg.NotFound
getUpdateMessage : Session -> Model -> UpdateMsg
getUpdateMessage session model =
if not session.featureFlags.resourceCausality then
UpdateMsg.NotFound
Err _ ->
UpdateMsg.AOK
else
case model.pageStatus of
Err NotFound ->
UpdateMsg.NotFound
Ok () ->
UpdateMsg.AOK
Err _ ->
UpdateMsg.AOK
Ok () ->
UpdateMsg.AOK
view : Session -> Model -> Html Message

View File

@ -22,6 +22,7 @@ module Concourse exposing
, CausalityResourceVersion
, ClusterInfo
, DatabaseID
, FeatureFlags
, HookedPlan
, InstanceGroupIdentifier
, InstanceVars
@ -181,9 +182,11 @@ type alias BuildId =
type alias BuildName =
String
type alias BuildCreatedBy =
Maybe String
type alias JobBuildIdentifier =
{ teamName : TeamName
, pipelineName : PipelineName
@ -202,7 +205,7 @@ type alias Build =
, duration : BuildDuration
, comment : String
, reapTime : Maybe Time.Posix
, createdBy: BuildCreatedBy
, createdBy : BuildCreatedBy
}
@ -834,9 +837,21 @@ decodeBuildStepAcross =
-- Info
type alias FeatureFlags =
{ resourceCausality : Bool
}
decodeFeatureFlags : Json.Decode.Decoder FeatureFlags
decodeFeatureFlags =
Json.Decode.succeed FeatureFlags
|> andMap (Json.Decode.field "resource_causality" Json.Decode.bool)
type alias ClusterInfo =
{ version : String
, clusterName : String
, featureFlags : FeatureFlags
}
@ -845,6 +860,7 @@ decodeInfo =
Json.Decode.succeed ClusterInfo
|> andMap (Json.Decode.field "version" Json.Decode.string)
|> andMap (defaultTo "" <| Json.Decode.field "cluster_name" Json.Decode.string)
|> andMap (Json.Decode.field "feature_flags" decodeFeatureFlags)

View File

@ -1226,6 +1226,7 @@ body :
, pipelines : WebData (List Concourse.Pipeline)
, hovered : HoverState.HoverState
, timeZone : Time.Zone
, featureFlags : Concourse.FeatureFlags
}
-> Model
-> Html Message
@ -1745,7 +1746,10 @@ isPipelineArchived session id =
viewVersionedResources :
{ a | pipelines : WebData (List Concourse.Pipeline) }
{ a
| pipelines : WebData (List Concourse.Pipeline)
, featureFlags : Concourse.FeatureFlags
}
->
{ b
| versions : Paginated Models.Version
@ -1760,16 +1764,17 @@ viewVersionedResources session model =
in
model
|> versions
|> List.map (\v -> viewVersionedResource { version = v, archived = archived })
|> List.map (\v -> viewVersionedResource { version = v, archived = archived, causalityEnabled = session.featureFlags.resourceCausality })
|> Html.ul [ class "list list-collapsable list-enableDisable resource-versions" ]
viewVersionedResource :
{ version : VersionPresenter
, archived : Bool
, causalityEnabled : Bool
}
-> Html Message
viewVersionedResource { version, archived } =
viewVersionedResource { version, archived, causalityEnabled } =
Html.li
(case ( version.pinState, version.enabled ) of
( Disabled, _ ) ->
@ -1816,6 +1821,7 @@ viewVersionedResource { version, archived } =
, outputOf = version.outputOf
, versionId = version.id
, metadata = version.metadata
, causalityEnabled = causalityEnabled
}
]
@ -1831,15 +1837,16 @@ viewVersionBody :
, outputOf : List Concourse.Build
, versionId : Concourse.VersionedResourceIdentifier
, metadata : Concourse.Metadata
, causalityEnabled : Bool
}
-> Html Message
viewVersionBody { inputTo, outputOf, versionId, metadata } =
viewVersionBody { inputTo, outputOf, versionId, metadata, causalityEnabled } =
Html.div
[ style "display" "flex"
, style "padding" "5px 10px"
]
[ viewInputsOrOutputs Concourse.Downstream versionId inputTo
, viewInputsOrOutputs Concourse.Upstream versionId outputOf
[ viewInputsOrOutputs causalityEnabled Concourse.Downstream versionId inputTo
, viewInputsOrOutputs causalityEnabled Concourse.Upstream versionId outputOf
, Html.div [ class "vri metadata-container" ]
[ Html.div [ class "list-collapsable-title" ] [ Html.text "metadata" ]
, viewMetadata metadata
@ -1967,20 +1974,20 @@ viewVersion attrs version =
|> DictView.view attrs
viewInputsOrOutputs : Concourse.CausalityDirection -> Concourse.VersionedResourceIdentifier -> List Concourse.Build -> Html Message
viewInputsOrOutputs direction versionId builds =
viewInputsOrOutputs : Bool -> Concourse.CausalityDirection -> Concourse.VersionedResourceIdentifier -> List Concourse.Build -> Html Message
viewInputsOrOutputs causalityEnabled direction versionId builds =
Html.div [ class "vri" ] <|
List.concat
[ [ Html.div
[ style "line-height" "25px" ]
[ viewCausalityButton direction versionId ]
[ viewCausalityButton causalityEnabled direction versionId ]
]
, viewBuilds <| listToMap builds
]
viewCausalityButton : Concourse.CausalityDirection -> Concourse.VersionedResourceIdentifier -> Html Message
viewCausalityButton dir versionId =
viewCausalityButton : Bool -> Concourse.CausalityDirection -> Concourse.VersionedResourceIdentifier -> Html Message
viewCausalityButton enabled dir versionId =
let
link =
Routes.Causality
@ -2003,21 +2010,25 @@ viewCausalityButton dir versionId =
, onClick <| GoToRoute link
]
in
Html.div
[ style "line-height" "25px"
, style "display" "flex"
, style "justify-content" "space-between"
]
[ Html.text text
, Html.a
([ href (Routes.toString link)
, id (toHtmlID <| domID)
]
++ eventHandlers
++ Resource.Styles.causalityButton
)
[ Html.text "view all" ]
]
if enabled then
Html.div
[ style "line-height" "25px"
, style "display" "flex"
, style "justify-content" "space-between"
]
[ Html.text text
, Html.a
([ href (Routes.toString link)
, id (toHtmlID <| domID)
]
++ eventHandlers
++ Resource.Styles.causalityButton
)
[ Html.text "view all" ]
]
else
Html.text text
viewMetadata : Concourse.Metadata -> Html Message

View File

@ -115,13 +115,13 @@ init session route =
|> Tuple.mapFirst CausalityModel
handleNotFound : String -> Routes.Route -> ET Model
handleNotFound notFound route ( model, effects ) =
case getUpdateMessage model of
handleNotFound : Session -> ET Model
handleNotFound session ( model, effects ) =
case getUpdateMessage session model of
UpdateMsg.NotFound ->
let
( newModel, newEffects ) =
NotFound.init { notFoundImgSrc = notFound, route = route }
NotFound.init { notFoundImgSrc = session.notFoundImgSrc, route = session.route }
in
( NotFoundModel newModel, effects ++ newEffects )
@ -129,8 +129,8 @@ handleNotFound notFound route ( model, effects ) =
( model, effects )
getUpdateMessage : Model -> UpdateMsg
getUpdateMessage model =
getUpdateMessage : Session -> Model -> UpdateMsg
getUpdateMessage session model =
case model of
BuildModel mdl ->
Build.getUpdateMessage mdl
@ -145,7 +145,7 @@ getUpdateMessage model =
Pipeline.getUpdateMessage mdl
CausalityModel mdl ->
Causality.getUpdateMessage mdl
Causality.getUpdateMessage session mdl
_ ->
UpdateMsg.AOK