├── .github
├── CODEOWNERS
└── FUNDING.yml
├── .gitignore
├── Dockerfile
├── LICENSE.md
├── NOTICE.md
├── README.md
├── check
└── main.go
├── check_command.go
├── check_command_test.go
├── go.mod
├── go.sum
├── in
└── main.go
├── in_command.go
├── in_command_test.go
├── lord
├── lord_suite_test.go
├── time_lord.go
└── time_lord_test.go
├── models
├── models.go
├── models_suite_test.go
└── source_test.go
├── offset.go
├── offset_test.go
├── out
└── main.go
├── out_command.go
├── out_command_test.go
└── resource_suite_test.go
/.github/CODEOWNERS:
--------------------------------------------------------------------------------
1 | * @concourse/maintainers
2 |
--------------------------------------------------------------------------------
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | github: [taylorsilva]
2 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | built-*
2 | .envrc
3 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | ARG base_image=cgr.dev/chainguard/wolfi-base
2 | ARG builder_image=concourse/golang-builder
3 |
4 | ARG BUILDPLATFORM
5 | FROM --platform=${BUILDPLATFORM} ${builder_image} AS builder
6 |
7 | ARG TARGETOS
8 | ARG TARGETARCH
9 | ENV GOOS=$TARGETOS
10 | ENV GOARCH=$TARGETARCH
11 |
12 | WORKDIR /concourse/time-resource
13 | COPY go.mod .
14 | COPY go.sum .
15 | RUN go mod download
16 | COPY . /concourse/time-resource
17 | ENV CGO_ENABLED 0
18 | RUN go build -o /assets/out github.com/concourse/time-resource/out
19 | RUN go build -o /assets/in github.com/concourse/time-resource/in
20 | RUN go build -o /assets/check github.com/concourse/time-resource/check
21 | RUN set -e; for pkg in $(go list ./...); do \
22 | go test -o "/tests/$(basename $pkg).test" -c $pkg; \
23 | done
24 |
25 | FROM ${base_image} AS resource
26 | COPY --from=builder /assets /opt/resource
27 | RUN apk --no-cache add tzdata
28 |
29 | FROM resource AS tests
30 | COPY --from=builder /tests /tests
31 | RUN set -e; for test in /tests/*.test; do \
32 | $test; \
33 | done
34 |
35 | FROM resource
36 |
--------------------------------------------------------------------------------
/LICENSE.md:
--------------------------------------------------------------------------------
1 | Apache License
2 | Version 2.0, January 2004
3 | http://www.apache.org/licenses/
4 |
5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6 |
7 | 1. Definitions.
8 |
9 | "License" shall mean the terms and conditions for use, reproduction,
10 | and distribution as defined by Sections 1 through 9 of this document.
11 |
12 | "Licensor" shall mean the copyright owner or entity authorized by
13 | the copyright owner that is granting the License.
14 |
15 | "Legal Entity" shall mean the union of the acting entity and all
16 | other entities that control, are controlled by, or are under common
17 | control with that entity. For the purposes of this definition,
18 | "control" means (i) the power, direct or indirect, to cause the
19 | direction or management of such entity, whether by contract or
20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the
21 | outstanding shares, or (iii) beneficial ownership of such entity.
22 |
23 | "You" (or "Your") shall mean an individual or Legal Entity
24 | exercising permissions granted by this License.
25 |
26 | "Source" form shall mean the preferred form for making modifications,
27 | including but not limited to software source code, documentation
28 | source, and configuration files.
29 |
30 | "Object" form shall mean any form resulting from mechanical
31 | transformation or translation of a Source form, including but
32 | not limited to compiled object code, generated documentation,
33 | and conversions to other media types.
34 |
35 | "Work" shall mean the work of authorship, whether in Source or
36 | Object form, made available under the License, as indicated by a
37 | copyright notice that is included in or attached to the work
38 | (an example is provided in the Appendix below).
39 |
40 | "Derivative Works" shall mean any work, whether in Source or Object
41 | form, that is based on (or derived from) the Work and for which the
42 | editorial revisions, annotations, elaborations, or other modifications
43 | represent, as a whole, an original work of authorship. For the purposes
44 | of this License, Derivative Works shall not include works that remain
45 | separable from, or merely link (or bind by name) to the interfaces of,
46 | the Work and Derivative Works thereof.
47 |
48 | "Contribution" shall mean any work of authorship, including
49 | the original version of the Work and any modifications or additions
50 | to that Work or Derivative Works thereof, that is intentionally
51 | submitted to Licensor for inclusion in the Work by the copyright owner
52 | or by an individual or Legal Entity authorized to submit on behalf of
53 | the copyright owner. For the purposes of this definition, "submitted"
54 | means any form of electronic, verbal, or written communication sent
55 | to the Licensor or its representatives, including but not limited to
56 | communication on electronic mailing lists, source code control systems,
57 | and issue tracking systems that are managed by, or on behalf of, the
58 | Licensor for the purpose of discussing and improving the Work, but
59 | excluding communication that is conspicuously marked or otherwise
60 | designated in writing by the copyright owner as "Not a Contribution."
61 |
62 | "Contributor" shall mean Licensor and any individual or Legal Entity
63 | on behalf of whom a Contribution has been received by Licensor and
64 | subsequently incorporated within the Work.
65 |
66 | 2. Grant of Copyright License. Subject to the terms and conditions of
67 | this License, each Contributor hereby grants to You a perpetual,
68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69 | copyright license to reproduce, prepare Derivative Works of,
70 | publicly display, publicly perform, sublicense, and distribute the
71 | Work and such Derivative Works in Source or Object form.
72 |
73 | 3. Grant of Patent License. Subject to the terms and conditions of
74 | this License, each Contributor hereby grants to You a perpetual,
75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76 | (except as stated in this section) patent license to make, have made,
77 | use, offer to sell, sell, import, and otherwise transfer the Work,
78 | where such license applies only to those patent claims licensable
79 | by such Contributor that are necessarily infringed by their
80 | Contribution(s) alone or by combination of their Contribution(s)
81 | with the Work to which such Contribution(s) was submitted. If You
82 | institute patent litigation against any entity (including a
83 | cross-claim or counterclaim in a lawsuit) alleging that the Work
84 | or a Contribution incorporated within the Work constitutes direct
85 | or contributory patent infringement, then any patent licenses
86 | granted to You under this License for that Work shall terminate
87 | as of the date such litigation is filed.
88 |
89 | 4. Redistribution. You may reproduce and distribute copies of the
90 | Work or Derivative Works thereof in any medium, with or without
91 | modifications, and in Source or Object form, provided that You
92 | meet the following conditions:
93 |
94 | (a) You must give any other recipients of the Work or
95 | Derivative Works a copy of this License; and
96 |
97 | (b) You must cause any modified files to carry prominent notices
98 | stating that You changed the files; and
99 |
100 | (c) You must retain, in the Source form of any Derivative Works
101 | that You distribute, all copyright, patent, trademark, and
102 | attribution notices from the Source form of the Work,
103 | excluding those notices that do not pertain to any part of
104 | the Derivative Works; and
105 |
106 | (d) If the Work includes a "NOTICE" text file as part of its
107 | distribution, then any Derivative Works that You distribute must
108 | include a readable copy of the attribution notices contained
109 | within such NOTICE file, excluding those notices that do not
110 | pertain to any part of the Derivative Works, in at least one
111 | of the following places: within a NOTICE text file distributed
112 | as part of the Derivative Works; within the Source form or
113 | documentation, if provided along with the Derivative Works; or,
114 | within a display generated by the Derivative Works, if and
115 | wherever such third-party notices normally appear. The contents
116 | of the NOTICE file are for informational purposes only and
117 | do not modify the License. You may add Your own attribution
118 | notices within Derivative Works that You distribute, alongside
119 | or as an addendum to the NOTICE text from the Work, provided
120 | that such additional attribution notices cannot be construed
121 | as modifying the License.
122 |
123 | You may add Your own copyright statement to Your modifications and
124 | may provide additional or different license terms and conditions
125 | for use, reproduction, or distribution of Your modifications, or
126 | for any such Derivative Works as a whole, provided Your use,
127 | reproduction, and distribution of the Work otherwise complies with
128 | the conditions stated in this License.
129 |
130 | 5. Submission of Contributions. Unless You explicitly state otherwise,
131 | any Contribution intentionally submitted for inclusion in the Work
132 | by You to the Licensor shall be under the terms and conditions of
133 | this License, without any additional terms or conditions.
134 | Notwithstanding the above, nothing herein shall supersede or modify
135 | the terms of any separate license agreement you may have executed
136 | with Licensor regarding such Contributions.
137 |
138 | 6. Trademarks. This License does not grant permission to use the trade
139 | names, trademarks, service marks, or product names of the Licensor,
140 | except as required for reasonable and customary use in describing the
141 | origin of the Work and reproducing the content of the NOTICE file.
142 |
143 | 7. Disclaimer of Warranty. Unless required by applicable law or
144 | agreed to in writing, Licensor provides the Work (and each
145 | Contributor provides its Contributions) on an "AS IS" BASIS,
146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147 | implied, including, without limitation, any warranties or conditions
148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149 | PARTICULAR PURPOSE. You are solely responsible for determining the
150 | appropriateness of using or redistributing the Work and assume any
151 | risks associated with Your exercise of permissions under this License.
152 |
153 | 8. Limitation of Liability. In no event and under no legal theory,
154 | whether in tort (including negligence), contract, or otherwise,
155 | unless required by applicable law (such as deliberate and grossly
156 | negligent acts) or agreed to in writing, shall any Contributor be
157 | liable to You for damages, including any direct, indirect, special,
158 | incidental, or consequential damages of any character arising as a
159 | result of this License or out of the use or inability to use the
160 | Work (including but not limited to damages for loss of goodwill,
161 | work stoppage, computer failure or malfunction, or any and all
162 | other commercial damages or losses), even if such Contributor
163 | has been advised of the possibility of such damages.
164 |
165 | 9. Accepting Warranty or Additional Liability. While redistributing
166 | the Work or Derivative Works thereof, You may choose to offer,
167 | and charge a fee for, acceptance of support, warranty, indemnity,
168 | or other liability obligations and/or rights consistent with this
169 | License. However, in accepting such obligations, You may act only
170 | on Your own behalf and on Your sole responsibility, not on behalf
171 | of any other Contributor, and only if You agree to indemnify,
172 | defend, and hold each Contributor harmless for any liability
173 | incurred by, or claims asserted against, such Contributor by reason
174 | of your accepting any such warranty or additional liability.
175 |
176 | END OF TERMS AND CONDITIONS
177 |
178 | APPENDIX: How to apply the Apache License to your work.
179 |
180 | To apply the Apache License to your work, attach the following
181 | boilerplate notice, with the fields enclosed by brackets "{}"
182 | replaced with your own identifying information. (Don't include
183 | the brackets!) The text should be enclosed in the appropriate
184 | comment syntax for the file format. We also recommend that a
185 | file or class name and description of purpose be included on the
186 | same "printed page" as the copyright notice for easier
187 | identification within third-party archives.
188 |
189 | Copyright {yyyy} {name of copyright owner}
190 |
191 | Licensed under the Apache License, Version 2.0 (the "License");
192 | you may not use this file except in compliance with the License.
193 | You may obtain a copy of the License at
194 |
195 | http://www.apache.org/licenses/LICENSE-2.0
196 |
197 | Unless required by applicable law or agreed to in writing, software
198 | distributed under the License is distributed on an "AS IS" BASIS,
199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200 | See the License for the specific language governing permissions and
201 | limitations under the License.
202 |
--------------------------------------------------------------------------------
/NOTICE.md:
--------------------------------------------------------------------------------
1 | Copyright 2014-2016 Alex Suraci, Chris Brown, and Pivotal Software, Inc.
2 |
3 | Licensed under the Apache License, Version 2.0 (the "License"); you may not use
4 | this file except in compliance with the License. You may obtain a copy of the
5 | License at
6 |
7 | http://www.apache.org/licenses/LICENSE-2.0
8 |
9 | Unless required by applicable law or agreed to in writing, software distributed
10 | under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR
11 | CONDITIONS OF ANY KIND, either express or implied. See the License for the
12 | specific language governing permissions and limitations under the License.
13 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Time Resource
2 |
3 | Implements a resource that reports new versions on a configured interval. The
4 | interval can be arbitrarily long.
5 |
6 |
7 |
8 |
9 |
10 | This resource is built to satisfy "trigger this build at least once every 5
11 | minutes," not "trigger this build on the 10th hour of every Sunday." That
12 | level of precision is better left to other tools.
13 |
14 | ## Source Configuration
15 |
16 | * `interval`: *Optional.* The interval on which to report new versions. Valid
17 | units are: “ns”, “us” (or “µs”), “ms”, “s”, “m”, “h”. Examples: `60s`, `90m`,
18 | `1h30m`. If not specified, this resource will generate exactly 1 new version
19 | per calendar day on each of the valid `days`.
20 |
21 | * `location`: *Optional. Default `UTC`.* The
22 | [location](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) in
23 | which to interpret `start`, `stop`, and `days`.
24 |
25 | e.g.
26 |
27 | ```
28 | location: Africa/Abidjan
29 | ```
30 |
31 | * `start` and `stop`: *Optional.* Limit the creation of new versions to times
32 | on/after `start` and before `stop`. The supported formats for the times are:
33 | `3:04 PM`, `3PM`, `3PM`, `15:04`, and `1504`. If a `start` is specified, a
34 | `stop` must also be specified, and vice versa. If neither value is specified,
35 | both values will default to `00:00` and this resource can generate a new
36 | version (based on `interval`) at any time of day.
37 |
38 | e.g.
39 |
40 | ```
41 | start: 8:00 PM
42 | stop: 9:00 PM
43 | ```
44 |
45 | **Deprecation: an offset may be appended, e.g. `+0700` or `-0400`, but you
46 | should use `location` instead.**
47 |
48 | To explicitly represent a full calendar day, set `start` and `stop` to
49 | the same value.
50 |
51 | e.g.
52 |
53 | ```
54 | start: 6:00 AM
55 | stop: 6:00 AM
56 | ```
57 |
58 | **Note: YAML parsers like PyYAML may parse time values in the 24h format as integers, not strings (e.g. `19:00` is parsed as `1140`). If you pre-process your pipeline configuration with such a parser this might trigger a marshaling error. In that case you can quote your `start` and `stop` values, so they will be correctly treated as string.**
59 |
60 | * `days`: *Optional.* Limit the creation of new time versions to the specified
61 | day(s). Supported days are: `Sunday`, `Monday`, `Tuesday`, `Wednesday`,
62 | `Thursday`, `Friday` and `Saturday`.
63 |
64 | e.g.
65 |
66 | ```
67 | days: [Monday, Wednesday]
68 | ```
69 |
70 | These can be combined to emit a new version on an interval during a particular
71 | time period.
72 |
73 | * `initial_version`: *Optional.* When using `start` and `stop` as a trigger for
74 | a job, you will be unable to run the job manually until it goes into the
75 | configured time range for the first time (manual runs will work once the `time`
76 | resource has produced it's first version).
77 |
78 | To get around this issue, there are two approaches:
79 | * Use `initial_version: true`, which will produce a new version that is
80 | set to the current time, if `check` runs and there isn't already a version
81 | specified. **NOTE: This has a downside that if used with `trigger: true`, it will
82 | kick off the correlating job when the pipeline is first created, even
83 | outside of the specified window**.
84 | * Alternatively, once you push a pipeline that utilizes `start` and `stop`, run the
85 | following fly command to run the resource check from a previous point
86 | in time (see [this issue](https://github.com/concourse/time-resource/issues/24#issuecomment-689422764)
87 | for 6.x.x+ or [this issue](https://github.com/concourse/time-resource/issues/11#issuecomment-562385742)
88 | for older Concourse versions).
89 |
90 | ```
91 | fly -t \
92 | check-resource --resource /
93 | --from "time:2000-01-01T00:00:00Z" # the important part
94 | ```
95 |
96 | This has the benefit that it shouldn't trigger that initial job run, but
97 | will still allow you to manually run the job if needed.
98 |
99 | e.g.
100 |
101 | ```
102 | initial_version: true
103 | ```
104 |
105 | ## Behavior
106 |
107 | ### `check`: Produce timestamps satisfying the interval.
108 |
109 | Returns current version and new version only if it has been longer than `interval` since the
110 | given version, or if there is no version given.
111 |
112 |
113 | ### `in`: Report the given time.
114 |
115 | Fetches the given timestamp. Creates three files:
116 | 1. `input` which contains the request provided by Concourse
117 | 1. `timestamp` which contains the fetched version in the following format: `2006-01-02 15:04:05.999999999 -0700 MST`
118 | 1. `epoch` which contains the fetched version as a Unix epoch Timestamp (integer only)
119 |
120 | #### Parameters
121 |
122 | *None.*
123 |
124 |
125 | ### `out`: Produce the current time.
126 |
127 | Returns a version for the current timestamp. This can be used to record the
128 | time within a build plan, e.g. after running some long-running task.
129 |
130 | #### Parameters
131 |
132 | *None.*
133 |
134 |
135 | ## Examples
136 |
137 | ### Periodic trigger
138 |
139 | ```yaml
140 | resources:
141 | - name: 5m
142 | type: time
143 | source: {interval: 5m}
144 |
145 | jobs:
146 | - name: something-every-5m
147 | plan:
148 | - get: 5m
149 | trigger: true
150 | - task: something
151 | config: # ...
152 | ```
153 |
154 | ### Trigger once within time range
155 |
156 | ```yaml
157 | resources:
158 | - name: after-midnight
159 | type: time
160 | source:
161 | start: 12:00 AM
162 | stop: 1:00 AM
163 | location: Asia/Sakhalin
164 |
165 | jobs:
166 | - name: something-after-midnight
167 | plan:
168 | - get: after-midnight
169 | trigger: true
170 | - task: something
171 | config: # ...
172 | ```
173 |
174 | ### Trigger on an interval within time range
175 |
176 | ```yaml
177 | resources:
178 | - name: 5m-during-midnight-hour
179 | type: time
180 | source:
181 | interval: 5m
182 | start: 12:00 AM
183 | stop: 1:00 AM
184 | location: America/Bahia_Banderas
185 |
186 | jobs:
187 | - name: something-every-5m-during-midnight-hour
188 | plan:
189 | - get: 5m-during-midnight-hour
190 | trigger: true
191 | - task: something
192 | config: # ...
193 | ```
194 |
195 | ## Development
196 |
197 | ### Prerequisites
198 |
199 | * golang is *required* - version 1.9.x is tested; earlier versions may also
200 | work.
201 | * docker is *required* - version 17.06.x is tested; earlier versions may also
202 | work.
203 | * go mod is used for dependency management of the golang packages.
204 |
205 | ### Running the tests
206 |
207 | The tests have been embedded with the `Dockerfile`; ensuring that the testing
208 | environment is consistent across any `docker` enabled platform. When the docker
209 | image builds, the test are run inside the docker container, on failure they
210 | will stop the build.
211 |
212 | Run the tests with the following command:
213 |
214 | ```sh
215 | docker build -t time-resource --target tests .
216 | ```
217 |
218 | ### Contributing
219 |
220 | Please make all pull requests to the `master` branch and ensure tests pass
221 | locally.
222 |
--------------------------------------------------------------------------------
/check/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "encoding/json"
5 | "fmt"
6 | "os"
7 |
8 | resource "github.com/concourse/time-resource"
9 | "github.com/concourse/time-resource/models"
10 | )
11 |
12 | func main() {
13 | var request models.CheckRequest
14 |
15 | err := json.NewDecoder(os.Stdin).Decode(&request)
16 | if err != nil {
17 | fmt.Fprintln(os.Stderr, "parse error:", err.Error())
18 | os.Exit(1)
19 | }
20 |
21 | command := resource.CheckCommand{}
22 |
23 | versions, err := command.Run(request)
24 | if err != nil {
25 | fmt.Fprintln(os.Stderr, "running command:", err.Error())
26 | os.Exit(1)
27 | }
28 |
29 | json.NewEncoder(os.Stdout).Encode(versions)
30 | }
31 |
--------------------------------------------------------------------------------
/check_command.go:
--------------------------------------------------------------------------------
1 | package resource
2 |
3 | import (
4 | "time"
5 |
6 | "github.com/concourse/time-resource/lord"
7 | "github.com/concourse/time-resource/models"
8 | )
9 |
10 | type CheckCommand struct {
11 | }
12 |
13 | func (*CheckCommand) Run(request models.CheckRequest) ([]models.Version, error) {
14 | err := request.Source.Validate()
15 | if err != nil {
16 | return nil, err
17 | }
18 |
19 | previousTime := request.Version.Time
20 | currentTime := time.Now().UTC()
21 |
22 | specifiedLocation := request.Source.Location
23 | if specifiedLocation != nil {
24 | currentTime = currentTime.In((*time.Location)(specifiedLocation))
25 | }
26 |
27 | tl := lord.TimeLord{
28 | PreviousTime: previousTime,
29 | Location: specifiedLocation,
30 | Start: request.Source.Start,
31 | Stop: request.Source.Stop,
32 | Interval: request.Source.Interval,
33 | Days: request.Source.Days,
34 | }
35 |
36 | var versions []models.Version
37 |
38 | if !previousTime.IsZero() {
39 | versions = append(versions, models.Version{Time: previousTime})
40 | } else if request.Source.InitialVersion {
41 | versions = append(versions, models.Version{Time: currentTime})
42 | return versions, nil
43 | }
44 |
45 | if tl.Check(currentTime) {
46 | versions = append(versions, models.Version{Time: currentTime})
47 | }
48 |
49 | return versions, nil
50 | }
51 |
--------------------------------------------------------------------------------
/check_command_test.go:
--------------------------------------------------------------------------------
1 | package resource_test
2 |
3 | import (
4 | "time"
5 |
6 | resource "github.com/concourse/time-resource"
7 | "github.com/concourse/time-resource/models"
8 |
9 | . "github.com/onsi/ginkgo/v2"
10 | . "github.com/onsi/gomega"
11 | )
12 |
13 | var _ = Describe("Check", func() {
14 | var (
15 | now time.Time
16 | )
17 |
18 | BeforeEach(func() {
19 | now = time.Now().UTC()
20 | })
21 |
22 | Context("when executed", func() {
23 | var source models.Source
24 | var version models.Version
25 | var response models.CheckResponse
26 |
27 | BeforeEach(func() {
28 | source = models.Source{}
29 | version = models.Version{}
30 | response = models.CheckResponse{}
31 | })
32 |
33 | JustBeforeEach(func() {
34 | command := resource.CheckCommand{}
35 |
36 | var err error
37 | response, err = command.Run(models.CheckRequest{
38 | Source: source,
39 | Version: version,
40 | })
41 | Expect(err).NotTo(HaveOccurred())
42 | })
43 |
44 | Context("when nothing is specified", func() {
45 | Context("when no version is given", func() {
46 | It("outputs a version containing the current time", func() {
47 | Expect(response).To(HaveLen(1))
48 | Expect(response[0].Time.Unix()).To(BeNumerically("~", time.Now().Unix(), 1))
49 | })
50 | })
51 |
52 | Context("when a version is given", func() {
53 | var prev time.Time
54 |
55 | Context("when the resource has already triggered on the current day", func() {
56 | BeforeEach(func() {
57 | prev = time.Date(now.Year(), now.Month(), now.Day(), now.Hour(), 0, now.Second(), now.Nanosecond(), now.Location())
58 | version.Time = prev
59 | })
60 |
61 | It("outputs a supplied version", func() {
62 | Expect(response).To(HaveLen(1))
63 | Expect(response[0].Time.Unix()).To(BeNumerically("~", prev.Unix(), 1))
64 | })
65 | })
66 |
67 | Context("when the resource was triggered yesterday", func() {
68 | BeforeEach(func() {
69 | prev = now.Add(-24 * time.Hour)
70 | version.Time = prev
71 | })
72 |
73 | It("outputs a version containing the current time and supplied version", func() {
74 | Expect(response).To(HaveLen(2))
75 | Expect(response[0].Time.Unix()).To(BeNumerically("~", prev.Unix(), 1))
76 | Expect(response[1].Time.Unix()).To(BeNumerically("~", time.Now().Unix(), 1))
77 | })
78 | })
79 | })
80 | })
81 |
82 | Context("when a time range is specified", func() {
83 | Context("when we are in the specified time range", func() {
84 | BeforeEach(func() {
85 | start := now.Add(-1 * time.Hour)
86 | stop := now.Add(1 * time.Hour)
87 |
88 | source.Start = tod(start.Hour(), start.Minute(), 0)
89 | source.Stop = tod(stop.Hour(), stop.Minute(), 0)
90 | })
91 |
92 | Context("when no version is given", func() {
93 | It("outputs a version containing the current time", func() {
94 | Expect(response).To(HaveLen(1))
95 | Expect(response[0].Time.Unix()).To(BeNumerically("~", time.Now().Unix(), 1))
96 | })
97 | })
98 |
99 | Context("when a version is given", func() {
100 | var prev time.Time
101 |
102 | Context("when the resource has already triggered with in the current time range", func() {
103 | BeforeEach(func() {
104 | prev = now.Add(-30 * time.Minute)
105 | version.Time = prev
106 | })
107 |
108 | It("outputs a supplied version", func() {
109 | Expect(response).To(HaveLen(1))
110 | Expect(response[0].Time.Unix()).To(BeNumerically("~", prev.Unix(), 1))
111 | })
112 | })
113 |
114 | Context("when the resource was triggered yesterday near the end of the time frame", func() {
115 | BeforeEach(func() {
116 | prev = now.Add(-23 * time.Hour)
117 | version.Time = prev
118 | })
119 |
120 | It("outputs a version containing the current time and supplied version", func() {
121 | Expect(response).To(HaveLen(2))
122 | Expect(response[0].Time.Unix()).To(BeNumerically("~", prev.Unix(), 1))
123 | Expect(response[1].Time.Unix()).To(BeNumerically("~", time.Now().Unix(), 1))
124 | })
125 | })
126 |
127 | Context("when the resource was triggered last year near the end of the time frame", func() {
128 | BeforeEach(func() {
129 | prev = now.AddDate(-1, 0, 0)
130 | version.Time = prev
131 | })
132 |
133 | It("outputs a version containing the current time and supplied version", func() {
134 | Expect(response).To(HaveLen(2))
135 | Expect(response[0].Time.Unix()).To(BeNumerically("~", prev.Unix(), 1))
136 | Expect(response[1].Time.Unix()).To(BeNumerically("~", time.Now().Unix(), 1))
137 | })
138 | })
139 |
140 | Context("when the resource was triggered yesterday in the current time frame", func() {
141 | BeforeEach(func() {
142 | prev = now.Add(-24 * time.Hour)
143 | version.Time = prev
144 | })
145 |
146 | It("outputs a version containing the current time and supplied version", func() {
147 | Expect(response).To(HaveLen(2))
148 | Expect(response[0].Time.Unix()).To(BeNumerically("~", prev.Unix(), 1))
149 | Expect(response[1].Time.Unix()).To(BeNumerically("~", time.Now().Unix(), 1))
150 | })
151 | })
152 | })
153 |
154 | Context("when an interval is specified", func() {
155 | BeforeEach(func() {
156 | interval := models.Interval(time.Minute)
157 | source.Interval = &interval
158 | })
159 |
160 | Context("when no version is given", func() {
161 | It("outputs a version containing the current time", func() {
162 | Expect(response).To(HaveLen(1))
163 | Expect(response[0].Time.Unix()).To(BeNumerically("~", time.Now().Unix(), 1))
164 | })
165 | })
166 |
167 | Context("when a version is given", func() {
168 | var prev time.Time
169 |
170 | Context("when the interval has not elapsed", func() {
171 | BeforeEach(func() {
172 | prev = now
173 | version.Time = prev
174 | })
175 |
176 | It("outputs only the supplied version", func() {
177 | Expect(response).To(HaveLen(1))
178 | Expect(response[0].Time.Unix()).To(Equal(prev.Unix()))
179 | })
180 | })
181 |
182 | Context("when the interval has elapsed", func() {
183 | BeforeEach(func() {
184 | prev = now.Add(-1 * time.Minute)
185 | version.Time = prev
186 | })
187 |
188 | It("outputs a version containing the current time and supplied version", func() {
189 | Expect(response).To(HaveLen(2))
190 | Expect(response[0].Time.Unix()).To(Equal(prev.Unix()))
191 | Expect(response[1].Time.Unix()).To(BeNumerically("~", time.Now().Unix(), 1))
192 | })
193 | })
194 |
195 | Context("with its time N intervals ago", func() {
196 | BeforeEach(func() {
197 | prev = now.Add(-5 * time.Minute)
198 | version.Time = prev
199 | })
200 |
201 | It("outputs a version containing the current time and supplied version", func() {
202 | Expect(response).To(HaveLen(2))
203 | Expect(response[0].Time.Unix()).To(Equal(prev.Unix()))
204 | Expect(response[1].Time.Unix()).To(BeNumerically("~", time.Now().Unix(), 1))
205 | })
206 | })
207 | })
208 | })
209 |
210 | Context("when the current day is specified", func() {
211 | BeforeEach(func() {
212 | source.Days = []models.Weekday{
213 | models.Weekday(now.Weekday()),
214 | models.Weekday(now.AddDate(0, 0, 2).Weekday()),
215 | }
216 | })
217 |
218 | It("outputs a version containing the current time", func() {
219 | Expect(response).To(HaveLen(1))
220 | Expect(response[0].Time.Unix()).To(BeNumerically("~", time.Now().Unix(), 1))
221 | })
222 | })
223 |
224 | Context("when we are out of the specified day", func() {
225 | BeforeEach(func() {
226 | source.Days = []models.Weekday{
227 | models.Weekday(now.AddDate(0, 0, 1).Weekday()),
228 | models.Weekday(now.AddDate(0, 0, 2).Weekday()),
229 | }
230 | })
231 |
232 | It("does not output any versions", func() {
233 | Expect(response).To(BeEmpty())
234 | })
235 | })
236 | })
237 |
238 | Context("when we are not within the specified time range", func() {
239 | BeforeEach(func() {
240 | start := now.Add(6 * time.Hour)
241 | stop := now.Add(7 * time.Hour)
242 |
243 | source.Start = tod(start.Hour(), start.Minute(), 0)
244 | source.Stop = tod(stop.Hour(), stop.Minute(), 0)
245 | })
246 |
247 | Context("when no version is given", func() {
248 | It("does not output any versions", func() {
249 | Expect(response).To(BeEmpty())
250 | })
251 | })
252 |
253 | Context("when an interval is given", func() {
254 | BeforeEach(func() {
255 | start := now.Add(6 * time.Hour)
256 | stop := now.Add(7 * time.Hour)
257 |
258 | source.Start = tod(start.Hour(), start.Minute(), 0)
259 | source.Stop = tod(stop.Hour(), stop.Minute(), 0)
260 |
261 | interval := models.Interval(time.Minute)
262 | source.Interval = &interval
263 | })
264 |
265 | It("does not output any versions", func() {
266 | Expect(response).To(BeEmpty())
267 | })
268 | })
269 | })
270 |
271 | Context("with a location configured", func() {
272 | var loc *time.Location
273 |
274 | BeforeEach(func() {
275 | var err error
276 | loc, err = time.LoadLocation("America/Indiana/Indianapolis")
277 | Expect(err).ToNot(HaveOccurred())
278 |
279 | srcLoc := models.Location(*loc)
280 | source.Location = &srcLoc
281 |
282 | now = now.In(loc)
283 | })
284 |
285 | Context("when we are in the specified time range", func() {
286 | BeforeEach(func() {
287 | start := now.Add(-1 * time.Hour)
288 | stop := now.Add(1 * time.Hour)
289 |
290 | source.Start = tod(start.Hour(), start.Minute(), 0)
291 | source.Stop = tod(stop.Hour(), stop.Minute(), 0)
292 | })
293 |
294 | Context("when no version is given", func() {
295 | It("outputs a version containing the current time", func() {
296 | Expect(response).To(HaveLen(1))
297 | Expect(response[0].Time.Unix()).To(BeNumerically("~", time.Now().Unix(), 1))
298 | })
299 | })
300 |
301 | Context("when a version is given", func() {
302 | var prev time.Time
303 |
304 | Context("when the resource has already triggered with in the current time range", func() {
305 | BeforeEach(func() {
306 | prev = now.Add(-30 * time.Minute)
307 | version.Time = prev
308 | })
309 |
310 | It("outputs a supplied version", func() {
311 | Expect(response).To(HaveLen(1))
312 | Expect(response[0].Time.Unix()).To(Equal(prev.Unix()))
313 | })
314 | })
315 |
316 | Context("when the resource was triggered yesterday near the end of the time frame", func() {
317 | BeforeEach(func() {
318 | prev = now.Add(-23 * time.Hour)
319 | version.Time = prev
320 | })
321 |
322 | It("outputs a version containing the current time and supplied version", func() {
323 | Expect(response).To(HaveLen(2))
324 | Expect(response[0].Time.Unix()).To(Equal(prev.Unix()))
325 | Expect(response[1].Time.Unix()).To(BeNumerically("~", time.Now().Unix(), 1))
326 | })
327 | })
328 |
329 | Context("when the resource was triggered yesterday in the current time frame", func() {
330 | BeforeEach(func() {
331 | prev = now.AddDate(0, 0, -1)
332 | version.Time = prev
333 | })
334 |
335 | It("outputs a version containing the current time and supplied version", func() {
336 | Expect(response).To(HaveLen(2))
337 | Expect(response[0].Time.Unix()).To(Equal(prev.Unix()))
338 | Expect(response[1].Time.Unix()).To(BeNumerically("~", time.Now().Unix(), 1))
339 | })
340 | })
341 | })
342 |
343 | Context("when an interval is specified", func() {
344 | BeforeEach(func() {
345 | interval := models.Interval(time.Minute)
346 | source.Interval = &interval
347 | })
348 |
349 | Context("when no version is given", func() {
350 | It("outputs a version containing the current time", func() {
351 | Expect(response).To(HaveLen(1))
352 | Expect(response[0].Time.Unix()).To(BeNumerically("~", time.Now().Unix(), 1))
353 | })
354 | })
355 |
356 | Context("when a version is given", func() {
357 | var prev time.Time
358 |
359 | Context("with its time within the interval", func() {
360 | BeforeEach(func() {
361 | prev = now
362 | version.Time = prev
363 | })
364 |
365 | It("outputs the given version", func() {
366 | Expect(response).To(HaveLen(1))
367 | Expect(response[0].Time.Unix()).To(Equal(prev.Unix()))
368 | })
369 | })
370 |
371 | Context("with its time one interval ago", func() {
372 | BeforeEach(func() {
373 | prev = now.Add(-1 * time.Minute)
374 | version.Time = prev
375 | })
376 |
377 | It("outputs a version containing the current time and supplied version", func() {
378 | Expect(response).To(HaveLen(2))
379 | Expect(response[0].Time.Unix()).To(Equal(prev.Unix()))
380 | Expect(response[1].Time.Unix()).To(BeNumerically("~", time.Now().Unix(), 1))
381 | })
382 | })
383 |
384 | Context("with its time N intervals ago", func() {
385 | BeforeEach(func() {
386 | prev = now.Add(-5 * time.Minute)
387 | version.Time = prev
388 | })
389 |
390 | It("outputs a version containing the current time and supplied version", func() {
391 | Expect(response).To(HaveLen(2))
392 | Expect(response[0].Time.Unix()).To(Equal(prev.Unix()))
393 | Expect(response[1].Time.Unix()).To(BeNumerically("~", time.Now().Unix(), 1))
394 | })
395 | })
396 | })
397 | })
398 |
399 | Context("when the current day is specified", func() {
400 | BeforeEach(func() {
401 | source.Days = []models.Weekday{
402 | models.Weekday(now.Weekday()),
403 | models.Weekday(now.AddDate(0, 0, 2).Weekday()),
404 | }
405 | })
406 |
407 | It("outputs a version containing the current time", func() {
408 | Expect(response).To(HaveLen(1))
409 | Expect(response[0].Time.Unix()).To(BeNumerically("~", time.Now().Unix(), 1))
410 | })
411 | })
412 |
413 | Context("when we are out of the specified day", func() {
414 | BeforeEach(func() {
415 | source.Days = []models.Weekday{
416 | models.Weekday(now.AddDate(0, 0, 1).Weekday()),
417 | models.Weekday(now.AddDate(0, 0, 2).Weekday()),
418 | }
419 | })
420 |
421 | It("does not output any versions", func() {
422 | Expect(response).To(BeEmpty())
423 | })
424 | })
425 | })
426 |
427 | Context("when we are not within the specified time range", func() {
428 | BeforeEach(func() {
429 | start := now.Add(6 * time.Hour)
430 | stop := now.Add(7 * time.Hour)
431 |
432 | source.Start = tod(start.Hour(), start.Minute(), 0)
433 | source.Stop = tod(stop.Hour(), stop.Minute(), 0)
434 | })
435 |
436 | Context("when no version is given", func() {
437 | It("does not output any versions", func() {
438 | Expect(response).To(BeEmpty())
439 | })
440 | })
441 |
442 | Context("when an interval is given", func() {
443 | BeforeEach(func() {
444 | start := now.Add(6 * time.Hour)
445 | stop := now.Add(7 * time.Hour)
446 |
447 | source.Start = tod(start.Hour(), start.Minute(), 0)
448 | source.Stop = tod(stop.Hour(), stop.Minute(), 0)
449 |
450 | interval := models.Interval(time.Minute)
451 | source.Interval = &interval
452 | })
453 |
454 | It("does not output any versions", func() {
455 | Expect(response).To(BeEmpty())
456 | })
457 | })
458 | })
459 | })
460 | })
461 |
462 | Context("when an interval is specified", func() {
463 | BeforeEach(func() {
464 | interval := models.Interval(time.Minute)
465 | source.Interval = &interval
466 | })
467 |
468 | Context("when no version is given", func() {
469 | It("outputs a version containing the current time", func() {
470 | Expect(response).To(HaveLen(1))
471 | Expect(response[0].Time.Unix()).To(BeNumerically("~", time.Now().Unix(), 1))
472 | })
473 | })
474 |
475 | Context("when a version is given", func() {
476 | var prev time.Time
477 |
478 | Context("with its time within the interval", func() {
479 | BeforeEach(func() {
480 | prev = now
481 | version.Time = prev
482 | })
483 |
484 | It("outputs a supplied version", func() {
485 | Expect(response).To(HaveLen(1))
486 | Expect(response[0].Time.Unix()).To(Equal(prev.Unix()))
487 | })
488 | })
489 |
490 | Context("with its time one interval ago", func() {
491 | BeforeEach(func() {
492 | prev = now.Add(-1 * time.Minute)
493 | version.Time = prev
494 | })
495 |
496 | It("outputs a version containing the current time and supplied version", func() {
497 | Expect(response).To(HaveLen(2))
498 | Expect(response[0].Time.Unix()).To(Equal(prev.Unix()))
499 | Expect(response[1].Time.Unix()).To(BeNumerically("~", time.Now().Unix(), 1))
500 | })
501 | })
502 |
503 | Context("with its time N intervals ago", func() {
504 | BeforeEach(func() {
505 | prev = now.Add(-5 * time.Minute)
506 | version.Time = prev
507 | })
508 |
509 | It("outputs a version containing the current time and supplied version", func() {
510 | Expect(response).To(HaveLen(2))
511 | Expect(response[0].Time.Unix()).To(Equal(prev.Unix()))
512 | Expect(response[1].Time.Unix()).To(BeNumerically("~", time.Now().Unix(), 1))
513 | })
514 | })
515 | })
516 | })
517 | })
518 | })
519 |
520 | func tod(hours, minutes, offset int) *models.TimeOfDay {
521 | loc := time.UTC
522 | if offset != 0 {
523 | loc = time.FixedZone("UnitTest", 60*60*offset)
524 | }
525 |
526 | now := time.Now()
527 | tod := models.NewTimeOfDay(time.Date(now.Year(), now.Month(), now.Day(), hours, minutes, 0, 0, loc))
528 |
529 | return &tod
530 | }
531 |
--------------------------------------------------------------------------------
/go.mod:
--------------------------------------------------------------------------------
1 | module github.com/concourse/time-resource
2 |
3 | go 1.24
4 |
5 | require (
6 | github.com/onsi/ginkgo/v2 v2.23.0
7 | github.com/onsi/gomega v1.36.2
8 | )
9 |
10 | require (
11 | github.com/go-logr/logr v1.4.2 // indirect
12 | github.com/go-task/slim-sprig/v3 v3.0.0 // indirect
13 | github.com/google/go-cmp v0.6.0 // indirect
14 | github.com/google/pprof v0.0.0-20241210010833-40e02aabc2ad // indirect
15 | golang.org/x/net v0.38.0 // indirect
16 | golang.org/x/sys v0.31.0 // indirect
17 | golang.org/x/text v0.23.0 // indirect
18 | golang.org/x/tools v0.30.0 // indirect
19 | gopkg.in/yaml.v3 v3.0.1 // indirect
20 | )
21 |
--------------------------------------------------------------------------------
/go.sum:
--------------------------------------------------------------------------------
1 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
2 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
3 | github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
4 | github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
5 | github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
6 | github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
7 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
8 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
9 | github.com/google/pprof v0.0.0-20241210010833-40e02aabc2ad h1:a6HEuzUHeKH6hwfN/ZoQgRgVIWFJljSWa/zetS2WTvg=
10 | github.com/google/pprof v0.0.0-20241210010833-40e02aabc2ad/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144=
11 | github.com/onsi/ginkgo/v2 v2.23.0 h1:FA1xjp8ieYDzlgS5ABTpdUDB7wtngggONc8a7ku2NqQ=
12 | github.com/onsi/ginkgo/v2 v2.23.0/go.mod h1:zXTP6xIp3U8aVuXN8ENK9IXRaTjFnpVB9mGmaSRvxnM=
13 | github.com/onsi/gomega v1.36.2 h1:koNYke6TVk6ZmnyHrCXba/T/MoLBXFjeC1PtvYgw0A8=
14 | github.com/onsi/gomega v1.36.2/go.mod h1:DdwyADRjrc825LhMEkD76cHR5+pUnjhUN8GlHlRPHzY=
15 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
16 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
17 | github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
18 | github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
19 | golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
20 | golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
21 | golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
22 | golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
23 | golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
24 | golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
25 | golang.org/x/tools v0.30.0 h1:BgcpHewrV5AUp2G9MebG4XPFI1E2W41zU1SaqVA9vJY=
26 | golang.org/x/tools v0.30.0/go.mod h1:c347cR/OJfw5TI+GfX7RUPNMdDRRbjvYTS0jPyvsVtY=
27 | google.golang.org/protobuf v1.36.1 h1:yBPeRvTftaleIgM3PZ/WBIZ7XM/eEYAaEyCwvyjq/gk=
28 | google.golang.org/protobuf v1.36.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
29 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
30 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
31 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
32 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
33 |
--------------------------------------------------------------------------------
/in/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "encoding/json"
5 | "fmt"
6 | "os"
7 |
8 | resource "github.com/concourse/time-resource"
9 | "github.com/concourse/time-resource/models"
10 | )
11 |
12 | func main() {
13 | if len(os.Args) < 2 {
14 | println("usage: " + os.Args[0] + " ")
15 | os.Exit(1)
16 | }
17 |
18 | destination := os.Args[1]
19 |
20 | var request models.InRequest
21 |
22 | err := json.NewDecoder(os.Stdin).Decode(&request)
23 | if err != nil {
24 | fmt.Fprintln(os.Stderr, "parse error:", err.Error())
25 | os.Exit(1)
26 | }
27 |
28 | command := resource.InCommand{}
29 |
30 | response, err := command.Run(destination, request)
31 | if err != nil {
32 | fmt.Fprintln(os.Stderr, "running command:", err.Error())
33 | os.Exit(1)
34 | }
35 |
36 | json.NewEncoder(os.Stdout).Encode(response)
37 | }
38 |
--------------------------------------------------------------------------------
/in_command.go:
--------------------------------------------------------------------------------
1 | package resource
2 |
3 | import (
4 | "encoding/json"
5 | "fmt"
6 | "os"
7 | "path/filepath"
8 | "strconv"
9 | "time"
10 |
11 | "github.com/concourse/time-resource/models"
12 | )
13 |
14 | type InCommand struct {
15 | }
16 |
17 | func (*InCommand) Run(destination string, request models.InRequest) (models.InResponse, error) {
18 | err := os.MkdirAll(destination, 0755)
19 | if err != nil {
20 | return models.InResponse{}, fmt.Errorf("creating destination: %w", err)
21 | }
22 |
23 | file, err := os.Create(filepath.Join(destination, "input"))
24 | if err != nil {
25 | return models.InResponse{}, fmt.Errorf("creating input file: %w", err)
26 | }
27 | defer file.Close()
28 |
29 | err = json.NewEncoder(file).Encode(request)
30 | if err != nil {
31 | return models.InResponse{}, fmt.Errorf("writing input file: %w", err)
32 | }
33 |
34 | versionTime := request.Version.Time
35 | if versionTime.IsZero() {
36 | versionTime = time.Now()
37 | }
38 |
39 | timeFile, err := os.Create(filepath.Join(destination, "timestamp"))
40 | if err != nil {
41 | return models.InResponse{}, fmt.Errorf("creating timestamp file: %w", err)
42 | }
43 | defer timeFile.Close()
44 |
45 | _, err = timeFile.WriteString(versionTime.Format("2006-01-02 15:04:05.999999999 -0700 MST"))
46 | if err != nil {
47 | return models.InResponse{}, fmt.Errorf("writing timestamp file: %w", err)
48 | }
49 |
50 | epochFile, err := os.Create(filepath.Join(destination, "epoch"))
51 | if err != nil {
52 | return models.InResponse{}, fmt.Errorf("creating epoch file: %w", err)
53 | }
54 | defer epochFile.Close()
55 | _, err = epochFile.WriteString(strconv.FormatInt(versionTime.Unix(), 10))
56 | if err != nil {
57 | return models.InResponse{}, fmt.Errorf("writing epoch file: %w", err)
58 | }
59 |
60 | inVersion := models.Version{Time: versionTime}
61 | response := models.InResponse{Version: inVersion}
62 |
63 | return response, nil
64 | }
65 |
--------------------------------------------------------------------------------
/in_command_test.go:
--------------------------------------------------------------------------------
1 | package resource_test
2 |
3 | import (
4 | "encoding/json"
5 | "os"
6 | "path"
7 | "path/filepath"
8 | "strconv"
9 | "time"
10 |
11 | resource "github.com/concourse/time-resource"
12 | "github.com/concourse/time-resource/models"
13 | . "github.com/onsi/ginkgo/v2"
14 | . "github.com/onsi/gomega"
15 | )
16 |
17 | var _ = Describe("In", func() {
18 | var (
19 | tmpdir string
20 | destination string
21 |
22 | source models.Source
23 | version models.Version
24 | response models.InResponse
25 |
26 | err error
27 | )
28 |
29 | BeforeEach(func() {
30 | tmpdir, err = os.MkdirTemp("", "in-destination")
31 | Expect(err).NotTo(HaveOccurred())
32 |
33 | destination = path.Join(tmpdir, "in-dir")
34 |
35 | version = models.Version{Time: time.Now()}
36 |
37 | interval := models.Interval(time.Second)
38 | source = models.Source{Interval: &interval}
39 |
40 | response = models.InResponse{}
41 | })
42 |
43 | JustBeforeEach(func() {
44 | command := resource.InCommand{}
45 | response, err = command.Run(destination, models.InRequest{
46 | Source: source,
47 | Version: version,
48 | })
49 | })
50 |
51 | AfterEach(func() {
52 | os.RemoveAll(tmpdir)
53 | })
54 |
55 | Context("when executed", func() {
56 |
57 | BeforeEach(func() {
58 | Expect(err).NotTo(HaveOccurred())
59 | })
60 |
61 | It("reports the version's time as the version", func() {
62 | Expect(response.Version.Time.UnixNano()).To(Equal(version.Time.UnixNano()))
63 | })
64 |
65 | It("writes the requested version and source to the destination", func() {
66 | input, err := os.Open(filepath.Join(destination, "input"))
67 | Expect(err).NotTo(HaveOccurred())
68 |
69 | var requested models.InRequest
70 | err = json.NewDecoder(input).Decode(&requested)
71 | Expect(err).NotTo(HaveOccurred())
72 |
73 | Expect(requested.Version.Time.Unix()).To(Equal(version.Time.Unix()))
74 | Expect(requested.Source).To(Equal(source))
75 | })
76 |
77 | It("writes the requested version to the destination", func() {
78 | input, err := os.ReadFile(filepath.Join(destination, "timestamp"))
79 | Expect(err).NotTo(HaveOccurred())
80 |
81 | epoch, err := os.ReadFile(filepath.Join(destination, "epoch"))
82 | Expect(err).NotTo(HaveOccurred())
83 | epochi, err := strconv.Atoi(string(epoch))
84 | Expect(err).NotTo(HaveOccurred())
85 |
86 | givenTime, err := time.Parse("2006-01-02 15:04:05.999999999 -0700 MST", string(input))
87 | Expect(err).NotTo(HaveOccurred())
88 | Expect(givenTime.Unix()).To(Equal(version.Time.Unix()))
89 | Expect(givenTime.Unix()).To(Equal(int64(epochi)))
90 | })
91 |
92 | Context("when the request has no time in its version", func() {
93 | BeforeEach(func() {
94 | version = models.Version{}
95 | })
96 |
97 | It("reports the current time as the version", func() {
98 | Expect(response.Version.Time.Unix()).To(BeNumerically("~", time.Now().Unix(), 1))
99 | })
100 | })
101 | })
102 | })
103 |
--------------------------------------------------------------------------------
/lord/lord_suite_test.go:
--------------------------------------------------------------------------------
1 | package lord_test
2 |
3 | import (
4 | . "github.com/onsi/ginkgo/v2"
5 | . "github.com/onsi/gomega"
6 |
7 | "testing"
8 | )
9 |
10 | func TestLord(t *testing.T) {
11 | RegisterFailHandler(Fail)
12 | RunSpecs(t, "Lord Suite")
13 | }
14 |
--------------------------------------------------------------------------------
/lord/time_lord.go:
--------------------------------------------------------------------------------
1 | package lord
2 |
3 | import (
4 | "time"
5 |
6 | "github.com/concourse/time-resource/models"
7 | )
8 |
9 | var DEFAULT_TIME_OF_DAY = models.TimeOfDay(time.Duration(0))
10 |
11 | type TimeLord struct {
12 | PreviousTime time.Time
13 | Location *models.Location
14 | Start *models.TimeOfDay
15 | Stop *models.TimeOfDay
16 | Interval *models.Interval
17 | Days []models.Weekday
18 | }
19 |
20 | func (tl TimeLord) Check(now time.Time) bool {
21 |
22 | start, stop := tl.LatestRangeBefore(now)
23 |
24 | if !tl.daysMatch(now) {
25 | return false
26 | }
27 |
28 | if !start.IsZero() && (now.Before(start) || !now.Before(stop)) {
29 | return false
30 | }
31 |
32 | if tl.PreviousTime.IsZero() {
33 | return true
34 | }
35 |
36 | if tl.Interval != nil {
37 | if now.Sub(tl.PreviousTime) >= time.Duration(*tl.Interval) {
38 | return true
39 | }
40 | } else if !start.IsZero() {
41 | return tl.PreviousTime.Before(start)
42 | }
43 |
44 | return false
45 | }
46 |
47 | func (tl TimeLord) Latest(reference time.Time) time.Time {
48 | if tl.PreviousTime.After(reference) {
49 | return time.Time{}
50 | }
51 |
52 | refInLoc := reference.In(tl.loc())
53 | for !tl.daysMatch(refInLoc) {
54 | refInLoc = refInLoc.AddDate(0, 0, -1)
55 | }
56 |
57 | start, stop := tl.LatestRangeBefore(refInLoc)
58 |
59 | if tl.PreviousTime.IsZero() && !reference.Before(stop) {
60 | return time.Time{}
61 | }
62 |
63 | if tl.Interval == nil {
64 | if tl.PreviousTime.After(start) {
65 | return time.Time{}
66 | }
67 | return start
68 | }
69 |
70 | tlDuration := time.Duration(*tl.Interval)
71 |
72 | var latestValidTime time.Time
73 | for intervalTime := start; !intervalTime.After(reference) && intervalTime.Before(stop); intervalTime = intervalTime.Add(tlDuration) {
74 | latestValidTime = intervalTime
75 | }
76 | return latestValidTime
77 | }
78 |
79 | func (tl TimeLord) List(reference time.Time) []time.Time {
80 | start := tl.PreviousTime
81 |
82 | var addForRange func(time.Time, time.Time)
83 | versions := []time.Time{}
84 |
85 | if tl.Interval == nil {
86 |
87 | if start.IsZero() {
88 | refRangeStart, refRangeEnd := tl.LatestRangeBefore(reference)
89 | if !reference.Before(refRangeEnd) {
90 | return versions
91 | }
92 | start = refRangeStart
93 | }
94 |
95 | addForRange = func(dailyStart, _ time.Time) {
96 | if !dailyStart.Before(start) && !dailyStart.After(reference) {
97 | versions = append(versions, dailyStart)
98 | }
99 | }
100 |
101 | } else {
102 | tlDuration := time.Duration(*tl.Interval)
103 |
104 | if start.IsZero() {
105 | start = reference
106 | }
107 |
108 | addForRange = func(dailyStart, dailyEnd time.Time) {
109 | intervalTime := dailyStart.Truncate(tlDuration)
110 |
111 | for intervalTime.Before(dailyStart) || intervalTime.Before(start) {
112 | intervalTime = intervalTime.Add(tlDuration)
113 | }
114 |
115 | for !intervalTime.After(reference) && intervalTime.Before(dailyEnd) {
116 | versions = append(versions, intervalTime)
117 | intervalTime = intervalTime.Add(tlDuration)
118 | }
119 | }
120 | }
121 |
122 | var dailyStart, dailyEnd time.Time
123 | for dailyInterval := start; !dailyStart.After(reference); dailyInterval = dailyInterval.AddDate(0, 0, 1) {
124 | if tl.daysMatch(dailyInterval) {
125 | dailyStart, dailyEnd = tl.LatestRangeBefore(dailyInterval)
126 | if dailyStart.After(reference) {
127 | break
128 | }
129 | addForRange(dailyStart, dailyEnd)
130 | }
131 | }
132 | return versions
133 | }
134 |
135 | func (tl TimeLord) daysMatch(now time.Time) bool {
136 | if len(tl.Days) == 0 {
137 | return true
138 | }
139 |
140 | todayInLoc := models.Weekday(now.In(tl.loc()).Weekday())
141 |
142 | for _, day := range tl.Days {
143 | if day == todayInLoc {
144 | return true
145 | }
146 | }
147 |
148 | return false
149 | }
150 |
151 | func (tl TimeLord) LatestRangeBefore(reference time.Time) (time.Time, time.Time) {
152 |
153 | tlStart := DEFAULT_TIME_OF_DAY
154 | if tl.Start != nil {
155 | tlStart = *tl.Start
156 | }
157 | tlStop := DEFAULT_TIME_OF_DAY
158 | if tl.Stop != nil {
159 | tlStop = *tl.Stop
160 | }
161 |
162 | refInLoc := reference.In(tl.loc())
163 |
164 | start := time.Date(refInLoc.Year(), refInLoc.Month(), refInLoc.Day(),
165 | tlStart.Hour(), tlStart.Minute(), 0, 0, tl.loc())
166 |
167 | if start.After(refInLoc) {
168 | start = start.AddDate(0, 0, -1)
169 | }
170 |
171 | stop := time.Date(start.Year(), start.Month(), start.Day(),
172 | tlStop.Hour(), tlStop.Minute(), 0, 0, tl.loc())
173 |
174 | if !stop.After(start) {
175 | stop = stop.AddDate(0, 0, 1)
176 | }
177 |
178 | return start, stop
179 | }
180 |
181 | func (tl TimeLord) loc() *time.Location {
182 | if tl.Location != nil {
183 | return (*time.Location)(tl.Location)
184 | }
185 |
186 | return time.UTC
187 | }
188 |
--------------------------------------------------------------------------------
/lord/time_lord_test.go:
--------------------------------------------------------------------------------
1 | package lord_test
2 |
3 | import (
4 | "time"
5 |
6 | . "github.com/onsi/ginkgo/v2"
7 | . "github.com/onsi/gomega"
8 |
9 | "github.com/concourse/time-resource/lord"
10 | "github.com/concourse/time-resource/models"
11 | )
12 |
13 | type expectedTime struct {
14 | isZero bool
15 | hour int
16 | minute int
17 | weekday time.Weekday
18 | }
19 |
20 | type testCase struct {
21 | interval string
22 |
23 | location string
24 |
25 | start string
26 | stop string
27 |
28 | days []time.Weekday
29 |
30 | prev string
31 | prevDay time.Weekday
32 |
33 | now string
34 | extraTime time.Duration
35 | nowDay time.Weekday
36 |
37 | result bool
38 | latest expectedTime
39 | list []expectedTime
40 | }
41 |
42 | const exampleFormatWithTZ = "3:04 PM -0700 2006"
43 | const exampleFormatWithoutTZ = "3:04 PM 2006"
44 |
45 | func (tc testCase) Run() {
46 | var tl lord.TimeLord
47 |
48 | if tc.location != "" {
49 | loc, err := time.LoadLocation(tc.location)
50 | Expect(err).NotTo(HaveOccurred())
51 |
52 | tl.Location = (*models.Location)(loc)
53 | }
54 |
55 | var format string
56 | if tl.Location != nil {
57 | format = exampleFormatWithoutTZ
58 | } else {
59 | format = exampleFormatWithTZ
60 | }
61 |
62 | if tc.start != "" {
63 | tc.start += " 2018"
64 | startTime, err := time.Parse(format, tc.start)
65 | Expect(err).NotTo(HaveOccurred())
66 |
67 | start := models.NewTimeOfDay(startTime.UTC())
68 | tl.Start = &start
69 | }
70 |
71 | if tc.stop != "" {
72 | tc.stop += " 2018"
73 | stopTime, err := time.Parse(format, tc.stop)
74 | Expect(err).NotTo(HaveOccurred())
75 |
76 | stop := models.NewTimeOfDay(stopTime.UTC())
77 | tl.Stop = &stop
78 | }
79 |
80 | if tc.interval != "" {
81 | interval, err := time.ParseDuration(tc.interval)
82 | Expect(err).NotTo(HaveOccurred())
83 |
84 | tl.Interval = (*models.Interval)(&interval)
85 | }
86 |
87 | tl.Days = make([]models.Weekday, len(tc.days))
88 | for i, d := range tc.days {
89 | tl.Days[i] = models.Weekday(d)
90 | }
91 |
92 | now, err := time.Parse(exampleFormatWithTZ, tc.now+" 2018")
93 | Expect(err).NotTo(HaveOccurred())
94 |
95 | for now.Weekday() != tc.nowDay {
96 | now = now.AddDate(0, 0, 1)
97 | }
98 |
99 | if tc.prev != "" {
100 | tc.prev += " 2018"
101 | prev, err := time.Parse(exampleFormatWithTZ, tc.prev)
102 | Expect(err).NotTo(HaveOccurred())
103 |
104 | for prev.Weekday() != tc.prevDay {
105 | prev = prev.AddDate(0, 0, 1)
106 | }
107 |
108 | tl.PreviousTime = prev
109 | }
110 |
111 | result := tl.Check(now.UTC())
112 | Expect(result).To(Equal(tc.result))
113 |
114 | latest := tl.Latest(now.UTC())
115 | Expect(latest.IsZero()).To(Equal(tc.latest.isZero))
116 | if !tc.latest.isZero {
117 | Expect(latest.Hour()).To(Equal(tc.latest.hour))
118 | Expect(latest.Minute()).To(Equal(tc.latest.minute))
119 | Expect(latest.Second()).To(Equal(0))
120 | Expect(latest.Weekday()).To(Equal(tc.latest.weekday))
121 | if tc.list == nil {
122 | tc.list = []expectedTime{tc.latest}
123 | }
124 | }
125 |
126 | list := tl.List(now.UTC())
127 | Expect(len(list)).To(Equal(len(tc.list)))
128 | for idx, actual := range list {
129 | expected := tc.list[idx]
130 | Expect(actual.Hour()).To(Equal(expected.hour))
131 | Expect(actual.Minute()).To(Equal(expected.minute))
132 | Expect(latest.Second()).To(Equal(0))
133 | Expect(actual.Weekday()).To(Equal(expected.weekday))
134 | }
135 | }
136 |
137 | var _ = DescribeTable("A range without a previous time", (testCase).Run,
138 | Entry("between the start and stop time", testCase{
139 | start: "2:00 AM +0000",
140 | stop: "4:00 AM +0000",
141 | now: "3:00 AM +0000",
142 | result: true,
143 | latest: expectedTime{hour: 2},
144 | }),
145 | Entry("between the start and stop time down to the minute", testCase{
146 | start: "2:01 AM +0000",
147 | stop: "2:03 AM +0000",
148 | now: "2:02 AM +0000",
149 | result: true,
150 | latest: expectedTime{hour: 2, minute: 1},
151 | }),
152 | Entry("not between the start and stop time", testCase{
153 | start: "2:00 AM +0000",
154 | stop: "4:00 AM +0000",
155 | now: "5:00 AM +0000",
156 | result: false,
157 | latest: expectedTime{isZero: true},
158 | }),
159 | Entry("after the stop time, down to the minute", testCase{
160 | start: "2:00 AM +0000",
161 | stop: "4:00 AM +0000",
162 | now: "4:10 AM +0000",
163 | result: false,
164 | latest: expectedTime{isZero: true},
165 | }),
166 | Entry("before the start time, down to the minute", testCase{
167 | start: "11:07 AM +0000",
168 | stop: "11:10 AM +0000",
169 | now: "11:05 AM +0000",
170 | result: false,
171 | latest: expectedTime{isZero: true},
172 | }),
173 | Entry("one nanosecond before the start time", testCase{
174 | start: "3:04 AM +0000",
175 | stop: "3:07 AM +0000",
176 | now: "3:03 AM +0000",
177 | extraTime: time.Minute - time.Nanosecond,
178 | result: false,
179 | latest: expectedTime{isZero: true},
180 | }),
181 | Entry("equal to the start time", testCase{
182 | start: "3:04 AM +0000",
183 | stop: "3:07 AM +0000",
184 | now: "3:04 AM +0000",
185 | result: true,
186 | latest: expectedTime{hour: 3, minute: 4},
187 | }),
188 | Entry("one nanosecond before the stop time", testCase{
189 | start: "3:04 AM +0000",
190 | stop: "3:07 AM +0000",
191 | now: "3:06 AM +0000",
192 | extraTime: time.Minute - time.Nanosecond,
193 | result: true,
194 | latest: expectedTime{hour: 3, minute: 4},
195 | }),
196 | Entry("equal to the stop time", testCase{
197 | start: "3:04 AM +0000",
198 | stop: "3:07 AM +0000",
199 | now: "3:07 AM +0000",
200 | result: false,
201 | latest: expectedTime{isZero: true},
202 | }),
203 |
204 | Entry("between the start and stop time but the stop time is before the start time, spanning more than a day", testCase{
205 | start: "5:00 AM +0000",
206 | stop: "1:00 AM +0000",
207 | now: "6:00 AM +0000",
208 | result: true,
209 | latest: expectedTime{hour: 5},
210 | }),
211 | Entry("between the start and stop time but the stop time is before the start time, spanning half a day", testCase{
212 | start: "8:00 PM +0000",
213 | stop: "8:00 AM +0000",
214 | now: "1:00 AM +0000",
215 | result: true,
216 | latest: expectedTime{hour: 20, weekday: time.Saturday},
217 | }),
218 | Entry("between the start and stop time but the stop time is before the start time and now is in the stop day", testCase{
219 | start: "8:00 PM +0000",
220 | stop: "8:00 AM +0000",
221 | now: "7:00 AM +0000",
222 | result: true,
223 | latest: expectedTime{hour: 20, weekday: time.Saturday},
224 | }),
225 |
226 | Entry("between the start and stop time but the compare time is in a different timezone", testCase{
227 | start: "2:00 AM -0600",
228 | stop: "6:00 AM -0600",
229 | now: "1:00 AM -0700",
230 | result: true,
231 | latest: expectedTime{hour: 8},
232 | }),
233 |
234 | Entry("covering almost a full day", testCase{
235 | start: "12:01 AM -0700",
236 | stop: "11:59 PM -0700",
237 | now: "1:10 AM +0000",
238 | result: true,
239 | latest: expectedTime{hour: 7, minute: 1, weekday: time.Saturday},
240 | }),
241 | )
242 |
243 | var _ = DescribeTable("A range with a previous time", (testCase).Run,
244 | Entry("an hour before start", testCase{
245 | start: "2:00 AM +0000",
246 | stop: "4:00 AM +0000",
247 | now: "3:00 AM +0000",
248 | prev: "1:00 AM +0000",
249 | result: true,
250 | latest: expectedTime{hour: 2},
251 | }),
252 | Entry("with stop before start and prev in the start day and now in the stop day", testCase{
253 | start: "10:00 AM +0000",
254 | stop: "5:00 AM +0000",
255 | now: "4:00 AM +0000",
256 | nowDay: time.Tuesday,
257 | prev: "11:00 AM +0000",
258 | prevDay: time.Monday,
259 | result: false,
260 | latest: expectedTime{isZero: true},
261 | }),
262 | Entry("with stop before start and prev outside the range and now in the stop day", testCase{
263 | start: "10:00 AM +0000",
264 | stop: "5:00 AM +0000",
265 | now: "4:00 AM +0000",
266 | nowDay: time.Tuesday,
267 | prev: "9:00 AM +0000",
268 | prevDay: time.Monday,
269 | result: true,
270 | latest: expectedTime{hour: 10, weekday: time.Monday},
271 | }),
272 | Entry("after now and in range on same day as now", testCase{
273 | start: "2:00 AM +0000",
274 | stop: "4:00 AM +0000",
275 | now: "3:00 AM +0000",
276 | prev: "3:30 AM +0000",
277 | result: false,
278 | latest: expectedTime{isZero: true},
279 | }),
280 | Entry("after now and out of range on same day as now", testCase{
281 | start: "2:00 AM +0000",
282 | stop: "4:00 AM +0000",
283 | now: "3:00 AM +0000",
284 | prev: "5:00 AM +0000",
285 | result: false,
286 | latest: expectedTime{isZero: true},
287 | }),
288 | )
289 |
290 | var _ = DescribeTable("A range with a location and no previous time", (testCase).Run,
291 | Entry("between the start and stop time in a given location", testCase{
292 | location: "America/Indiana/Indianapolis",
293 | start: "1:00 PM",
294 | stop: "3:00 PM",
295 | now: "6:00 PM +0000",
296 | result: true,
297 | latest: expectedTime{hour: 13},
298 | }),
299 | Entry("between the start and stop time in a given location on a matching day", testCase{
300 | location: "America/Indiana/Indianapolis",
301 | start: "1:00 PM",
302 | stop: "3:00 PM",
303 | days: []time.Weekday{time.Wednesday},
304 | now: "6:00 PM +0000",
305 | nowDay: time.Wednesday,
306 | result: true,
307 | latest: expectedTime{hour: 13, weekday: time.Wednesday},
308 | }),
309 | Entry("not between the start and stop time in a given location", testCase{
310 | location: "America/Indiana/Indianapolis",
311 | start: "1:00 PM",
312 | stop: "3:00 PM",
313 | now: "8:00 PM +0000",
314 | result: false,
315 | latest: expectedTime{isZero: true},
316 | }),
317 | Entry("between the start and stop time in a given location but not on a matching day", testCase{
318 | location: "America/Indiana/Indianapolis",
319 | start: "1:00 PM",
320 | stop: "3:00 PM",
321 | days: []time.Weekday{time.Wednesday},
322 | now: "6:00 PM +0000",
323 | nowDay: time.Thursday,
324 | result: false,
325 | latest: expectedTime{isZero: true},
326 | }),
327 | Entry("between the start and stop time in a given location and on a matching day compared to UTC", testCase{
328 | location: "America/Indiana/Indianapolis",
329 | start: "9:00 PM",
330 | stop: "11:00 PM",
331 | days: []time.Weekday{time.Wednesday},
332 | now: "2:00 AM +0000",
333 | nowDay: time.Thursday,
334 | result: true,
335 | latest: expectedTime{hour: 21, weekday: time.Wednesday},
336 | }),
337 | )
338 |
339 | var _ = DescribeTable("A range with a location and a previous time", (testCase).Run,
340 | Entry("between the start and stop time in a given location, on a new day", testCase{
341 | location: "America/Indiana/Indianapolis",
342 | start: "1:00 PM",
343 | stop: "3:00 PM",
344 |
345 | prev: "6:00 PM +0000",
346 | prevDay: time.Wednesday,
347 | now: "6:00 PM +0000",
348 | nowDay: time.Thursday,
349 |
350 | result: true,
351 | latest: expectedTime{hour: 13, weekday: time.Thursday},
352 | list: []expectedTime{
353 | {hour: 13, weekday: time.Wednesday},
354 | {hour: 13, weekday: time.Thursday},
355 | },
356 | }),
357 | Entry("not between the start and stop time in a given location, on the same day", testCase{
358 | location: "America/Indiana/Indianapolis",
359 | start: "1:00 PM",
360 | stop: "3:00 PM",
361 |
362 | prev: "6:00 PM +0000",
363 | prevDay: time.Wednesday,
364 | now: "6:01 PM +0000",
365 | nowDay: time.Wednesday,
366 |
367 | result: false,
368 | latest: expectedTime{hour: 13, weekday: time.Wednesday},
369 | }),
370 | )
371 |
372 | var _ = DescribeTable("An interval", (testCase).Run,
373 | Entry("without a previous time", testCase{
374 | interval: "2m",
375 | now: "12:00 PM +0000",
376 | result: true,
377 | latest: expectedTime{hour: 12},
378 | }),
379 | Entry("with a previous time that has not elapsed", testCase{
380 | interval: "2m",
381 | prev: "12:00 PM +0000",
382 | now: "12:01 PM +0000",
383 | result: false,
384 | latest: expectedTime{hour: 12},
385 | }),
386 | Entry("with a previous time that has elapsed", testCase{
387 | interval: "2m",
388 | prev: "12:00 PM +0000",
389 | now: "12:02 PM +0000",
390 | result: true,
391 | latest: expectedTime{hour: 12, minute: 2},
392 | list: []expectedTime{
393 | {hour: 12},
394 | {hour: 12, minute: 2},
395 | },
396 | }),
397 | )
398 |
399 | var _ = DescribeTable("A range with an interval and a previous time", (testCase).Run,
400 | Entry("between the start and stop time, on a new day", testCase{
401 | interval: "2m",
402 |
403 | start: "1:00 PM +0000",
404 | stop: "3:00 PM +0000",
405 |
406 | prev: "2:58 PM +0000",
407 | prevDay: time.Wednesday,
408 | now: "1:00 PM +0000",
409 | nowDay: time.Thursday,
410 |
411 | result: true,
412 | latest: expectedTime{hour: 13, weekday: time.Thursday},
413 | list: []expectedTime{
414 | {hour: 14, minute: 58, weekday: time.Wednesday},
415 | {hour: 13, weekday: time.Thursday},
416 | },
417 | }),
418 | Entry("between the start and stop time, elapsed", testCase{
419 | interval: "2m",
420 |
421 | start: "1:00 PM +0000",
422 | stop: "3:00 PM +0000",
423 |
424 | prev: "1:02 PM +0000",
425 | now: "1:04 PM +0000",
426 |
427 | result: true,
428 | latest: expectedTime{hour: 13, minute: 4},
429 | list: []expectedTime{
430 | {hour: 13, minute: 2},
431 | {hour: 13, minute: 4},
432 | },
433 | }),
434 | Entry("between the start and stop time, not elapsed", testCase{
435 | interval: "2m",
436 |
437 | start: "1:00 PM +0000",
438 | stop: "3:00 PM +0000",
439 |
440 | prev: "1:02 PM +0000",
441 | now: "1:03 PM +0000",
442 |
443 | result: false,
444 | latest: expectedTime{hour: 13, minute: 2},
445 | }),
446 | Entry("not between the start and stop time, elapsed", testCase{
447 | interval: "2m",
448 |
449 | start: "1:00 PM +0000",
450 | stop: "3:00 PM +0000",
451 |
452 | prev: "2:58 PM +0000",
453 | now: "3:02 PM +0000",
454 |
455 | result: false,
456 | latest: expectedTime{hour: 14, minute: 58},
457 | }),
458 | )
459 |
--------------------------------------------------------------------------------
/models/models.go:
--------------------------------------------------------------------------------
1 | package models
2 |
3 | import (
4 | "encoding/json"
5 | "errors"
6 | "fmt"
7 | "strings"
8 | "time"
9 | )
10 |
11 | type Version struct {
12 | Time time.Time `json:"time"`
13 | }
14 |
15 | type InRequest struct {
16 | Source Source `json:"source"`
17 | Version Version `json:"version"`
18 | }
19 |
20 | type InResponse struct {
21 | Version Version `json:"version"`
22 | Metadata Metadata `json:"metadata"`
23 | }
24 |
25 | type OutRequest struct {
26 | Source Source `json:"source"`
27 | }
28 |
29 | type OutResponse struct {
30 | Version Version `json:"version"`
31 | Metadata Metadata `json:"metadata"`
32 | }
33 |
34 | type CheckRequest struct {
35 | Source Source `json:"source"`
36 | Version Version `json:"version"`
37 | }
38 |
39 | type CheckResponse []Version
40 |
41 | type Source struct {
42 | InitialVersion bool `json:"initial_version"`
43 | Interval *Interval `json:"interval"`
44 | Start *TimeOfDay `json:"start"`
45 | Stop *TimeOfDay `json:"stop"`
46 | Days []Weekday `json:"days"`
47 | Location *Location `json:"location"`
48 | }
49 |
50 | func (source Source) Validate() error {
51 | // Validate start and stop are both set or both unset
52 | if (source.Start != nil) != (source.Stop != nil) {
53 | if source.Start != nil {
54 | return errors.New("must configure 'stop' if 'start' is set")
55 | }
56 | return errors.New("must configure 'start' if 'stop' is set")
57 | }
58 |
59 | // Validate days if specified
60 | for _, day := range source.Days {
61 | if day < 0 || day > 6 {
62 | return fmt.Errorf("invalid day: %v", day)
63 | }
64 | }
65 |
66 | return nil
67 | }
68 |
69 | type Metadata []MetadataField
70 |
71 | type MetadataField struct {
72 | Name string `json:"name"`
73 | Value string `json:"value"`
74 | }
75 |
76 | type Interval time.Duration
77 |
78 | func (i *Interval) UnmarshalJSON(payload []byte) error {
79 | var durStr string
80 | err := json.Unmarshal(payload, &durStr)
81 | if err != nil {
82 | return err
83 | }
84 |
85 | duration, err := time.ParseDuration(durStr)
86 | if err != nil {
87 | return err
88 | }
89 |
90 | *i = Interval(duration)
91 |
92 | return nil
93 | }
94 |
95 | func (i Interval) MarshalJSON() ([]byte, error) {
96 | return json.Marshal(time.Duration(i).String())
97 | }
98 |
99 | type Location time.Location
100 |
101 | func (l *Location) UnmarshalJSON(payload []byte) error {
102 | var locStr string
103 | err := json.Unmarshal(payload, &locStr)
104 | if err != nil {
105 | return err
106 | }
107 |
108 | location, err := time.LoadLocation(locStr)
109 | if err != nil {
110 | return err
111 | }
112 |
113 | *l = Location(*location)
114 |
115 | return nil
116 | }
117 |
118 | func (l Location) MarshalJSON() ([]byte, error) {
119 | return json.Marshal((*time.Location)(&l).String())
120 | }
121 |
122 | var timeFormats []string
123 |
124 | func init() {
125 | timeFormats = append(timeFormats, "3:04 PM -0700")
126 | timeFormats = append(timeFormats, "3PM -0700")
127 | timeFormats = append(timeFormats, "3 PM -0700")
128 | timeFormats = append(timeFormats, "15:04 -0700")
129 | timeFormats = append(timeFormats, "1504 -0700")
130 | timeFormats = append(timeFormats, "3:04 PM")
131 | timeFormats = append(timeFormats, "3PM")
132 | timeFormats = append(timeFormats, "3 PM")
133 | timeFormats = append(timeFormats, "15:04")
134 | timeFormats = append(timeFormats, "1504")
135 | }
136 |
137 | type TimeOfDay time.Duration
138 |
139 | func NewTimeOfDay(t time.Time) TimeOfDay {
140 | return TimeOfDay(time.Duration(t.Hour())*time.Hour + time.Duration(t.Minute())*time.Minute)
141 | }
142 |
143 | func (tod *TimeOfDay) UnmarshalJSON(payload []byte) error {
144 | var timeStr string
145 | err := json.Unmarshal(payload, &timeStr)
146 | if err != nil {
147 | return err
148 | }
149 |
150 | var t time.Time
151 | for _, format := range timeFormats {
152 | t, err = time.Parse(format, timeStr)
153 | if err == nil {
154 | break
155 | }
156 | }
157 | if err != nil {
158 | return fmt.Errorf("invalid time format: %s, must be one of: %s", timeStr, strings.Join(timeFormats, ", "))
159 | }
160 |
161 | *tod = NewTimeOfDay(t.UTC())
162 |
163 | return nil
164 | }
165 |
166 | func (tod TimeOfDay) MarshalJSON() ([]byte, error) {
167 | return json.Marshal(tod.String())
168 | }
169 |
170 | func (tod TimeOfDay) Hour() int {
171 | return int(time.Duration(tod) / time.Hour)
172 | }
173 |
174 | func (tod TimeOfDay) Minute() int {
175 | return int(time.Duration(tod) % time.Hour / time.Minute)
176 | }
177 |
178 | func (tod TimeOfDay) String() string {
179 | return fmt.Sprintf("%d:%02d", tod.Hour(), tod.Minute())
180 | }
181 |
182 | type Weekday time.Weekday
183 |
184 | func ParseWeekday(wdStr string) (time.Weekday, error) {
185 | switch strings.ToLower(wdStr) {
186 | case "sun", "sunday":
187 | return time.Sunday, nil
188 | case "mon", "monday":
189 | return time.Monday, nil
190 | case "tue", "tuesday":
191 | return time.Tuesday, nil
192 | case "wed", "wednesday":
193 | return time.Wednesday, nil
194 | case "thu", "thursday":
195 | return time.Thursday, nil
196 | case "fri", "friday":
197 | return time.Friday, nil
198 | case "sat", "saturday":
199 | return time.Saturday, nil
200 | }
201 |
202 | return 0, fmt.Errorf("unknown weekday: %s", wdStr)
203 | }
204 |
205 | func (x *Weekday) UnmarshalJSON(payload []byte) error {
206 | var wdStr string
207 | err := json.Unmarshal(payload, &wdStr)
208 | if err != nil {
209 | return err
210 | }
211 |
212 | wd, err := ParseWeekday(wdStr)
213 | if err != nil {
214 | return err
215 | }
216 |
217 | *x = Weekday(wd)
218 |
219 | return nil
220 | }
221 |
222 | func (wd Weekday) MarshalJSON() ([]byte, error) {
223 | return json.Marshal(time.Weekday(wd).String())
224 | }
225 |
--------------------------------------------------------------------------------
/models/models_suite_test.go:
--------------------------------------------------------------------------------
1 | package models_test
2 |
3 | import (
4 | "testing"
5 |
6 | . "github.com/onsi/ginkgo/v2"
7 | . "github.com/onsi/gomega"
8 | )
9 |
10 | func TestModels(t *testing.T) {
11 | RegisterFailHandler(Fail)
12 | RunSpecs(t, "Models Suite")
13 | }
14 |
--------------------------------------------------------------------------------
/models/source_test.go:
--------------------------------------------------------------------------------
1 | package models_test
2 |
3 | import (
4 | "encoding/json"
5 |
6 | . "github.com/onsi/ginkgo/v2"
7 | . "github.com/onsi/gomega"
8 |
9 | "github.com/concourse/time-resource/models"
10 | )
11 |
12 | var _ = Describe("Source", func() {
13 | var (
14 | config string
15 |
16 | source models.Source
17 | err error
18 | )
19 |
20 | BeforeEach(func() {
21 | config = ""
22 | source = models.Source{}
23 | err = nil
24 | })
25 |
26 | JustBeforeEach(func() {
27 | err = json.Unmarshal([]byte(config), &source)
28 | })
29 |
30 | Context("a start with no stop", func() {
31 | BeforeEach(func() {
32 | config = `{ "start": "3:04" }`
33 | })
34 |
35 | It("generates a validation error", func() {
36 | Expect(err).ToNot(HaveOccurred())
37 |
38 | err = source.Validate()
39 | Expect(err).To(HaveOccurred())
40 | Expect(err.Error()).To(Equal("must configure 'stop' if 'start' is set"))
41 | })
42 | })
43 |
44 | Context("a stop with no start", func() {
45 | BeforeEach(func() {
46 | config = `{ "stop": "3:04" }`
47 | })
48 |
49 | It("generates a validation error", func() {
50 | Expect(err).ToNot(HaveOccurred())
51 |
52 | err = source.Validate()
53 | Expect(err).To(HaveOccurred())
54 | Expect(err.Error()).To(Equal("must configure 'start' if 'stop' is set"))
55 | })
56 | })
57 |
58 | Context("when the range is given in another timezone", func() {
59 | BeforeEach(func() {
60 | config = `{ "start": "3:04 -0100", "stop": "9:04 -0700" }`
61 | })
62 |
63 | It("is valid", func() {
64 | Expect(err).ToNot(HaveOccurred())
65 |
66 | err = source.Validate()
67 | Expect(err).ToNot(HaveOccurred())
68 |
69 | Expect(source.Start).ToNot(BeNil())
70 | Expect(source.Stop).ToNot(BeNil())
71 |
72 | Expect(source.Stop.Minute()).To(Equal(source.Start.Minute()))
73 | Expect(source.Stop.Hour()).To(Equal(source.Start.Hour() + 12))
74 | })
75 | })
76 | })
77 |
--------------------------------------------------------------------------------
/offset.go:
--------------------------------------------------------------------------------
1 | package resource
2 |
3 | import (
4 | "fmt"
5 | "hash/fnv"
6 | "math"
7 | "os"
8 | "time"
9 |
10 | "github.com/concourse/time-resource/lord"
11 | )
12 |
13 | const BUILD_TEAM_NAME = "BUILD_TEAM_NAME"
14 | const BUILD_PIPELINE_NAME = "BUILD_PIPELINE_NAME"
15 | const BUILD_PIPELINE_INSTANCE_VARS = "BUILD_PIPELINE_INSTANCE_VARS"
16 |
17 | const maxHashValue = int64(math.MaxUint32)
18 |
19 | var msPerMinute = time.Minute.Milliseconds()
20 |
21 | func Offset(tl lord.TimeLord, reference time.Time) time.Time {
22 | str := fmt.Sprintf(
23 | "%s/%s/%s",
24 | os.Getenv(BUILD_TEAM_NAME),
25 | os.Getenv(BUILD_PIPELINE_NAME),
26 | os.Getenv(BUILD_PIPELINE_INSTANCE_VARS),
27 | )
28 | hasher := fnv.New32a()
29 | if _, err := hasher.Write([]byte(str)); err != nil {
30 | fmt.Fprintln(os.Stderr, "hash error:", err.Error())
31 | os.Exit(1)
32 | }
33 | hash := int64(hasher.Sum32())
34 |
35 | start, stop := tl.LatestRangeBefore(reference)
36 | rangeDuration := stop.Sub(start)
37 |
38 | if tl.Interval != nil {
39 | if intervalDuration := time.Duration(*tl.Interval); intervalDuration < rangeDuration {
40 | rangeDuration = intervalDuration
41 | start = reference.Truncate(rangeDuration)
42 | }
43 | }
44 |
45 | if rangeDuration <= time.Minute {
46 | return start
47 | }
48 |
49 | rangeMs := rangeDuration.Milliseconds()
50 | if rangeMs <= 0 {
51 | return start
52 | }
53 |
54 | minutesInRange := rangeMs / msPerMinute
55 | if minutesInRange <= 0 {
56 | minutesInRange = 1
57 | }
58 |
59 | hashPerMinute := maxHashValue / minutesInRange
60 | if hashPerMinute <= 0 {
61 | hashPerMinute = 1
62 | }
63 |
64 | minutesToOffset := hash / hashPerMinute
65 |
66 | // Guard against overflows
67 | if minutesToOffset < 0 {
68 | minutesToOffset = 0
69 | }
70 |
71 | // Ensure the offset doesn't exceed the range duration
72 | if minutesToOffset > minutesInRange {
73 | minutesToOffset = minutesInRange
74 | }
75 |
76 | offsetDuration := time.Duration(minutesToOffset) * time.Minute
77 | return start.Add(offsetDuration)
78 | }
79 |
--------------------------------------------------------------------------------
/offset_test.go:
--------------------------------------------------------------------------------
1 | package resource_test
2 |
3 | import (
4 | "os"
5 | "time"
6 |
7 | resource "github.com/concourse/time-resource"
8 | "github.com/concourse/time-resource/lord"
9 | "github.com/concourse/time-resource/models"
10 | . "github.com/onsi/ginkgo/v2"
11 | . "github.com/onsi/gomega"
12 | )
13 |
14 | type testEnvironment struct {
15 | teamName string
16 | pipelineName string
17 | pipelineInstanceVars string
18 | hashPercentile float64
19 | }
20 |
21 | var xsmallOffset = testEnvironment{
22 | teamName: "a",
23 | pipelineName: "b",
24 | pipelineInstanceVars: "",
25 | hashPercentile: 0.11040721324049105,
26 | }
27 |
28 | var smallOffset = testEnvironment{
29 | teamName: "concourse",
30 | pipelineName: "time-resource",
31 | pipelineInstanceVars: "",
32 | hashPercentile: 0.4723325777967303,
33 | }
34 |
35 | var largeOffset = testEnvironment{
36 | teamName: "concourse",
37 | pipelineName: "concourse",
38 | pipelineInstanceVars: "large",
39 | hashPercentile: 0.8069350870342309,
40 | }
41 |
42 | var xlargeOffset = testEnvironment{
43 | teamName: "foo",
44 | pipelineName: "bar",
45 | pipelineInstanceVars: "baz",
46 | hashPercentile: 0.9322489704778998,
47 | }
48 |
49 | var _ = Describe("Offset", func() {
50 | originalTeam := os.Getenv(resource.BUILD_TEAM_NAME)
51 | originalPipeline := os.Getenv(resource.BUILD_PIPELINE_NAME)
52 | originalPipelineInstanceVars := os.Getenv(resource.BUILD_PIPELINE_INSTANCE_VARS)
53 |
54 | var (
55 | env testEnvironment
56 | now time.Time
57 | loc *time.Location
58 |
59 | tl lord.TimeLord
60 | dayDuration time.Duration
61 | reference time.Time
62 |
63 | actualOffsetTime time.Time
64 | expectedOffsetTime time.Time
65 | )
66 |
67 | BeforeEach(func() {
68 | env = testEnvironment{}
69 | now = time.Now()
70 | actualOffsetTime = time.Time{}
71 | expectedOffsetTime = time.Time{}
72 | })
73 |
74 | JustBeforeEach(func() {
75 | os.Setenv(resource.BUILD_TEAM_NAME, env.teamName)
76 | os.Setenv(resource.BUILD_PIPELINE_NAME, env.pipelineName)
77 | os.Setenv(resource.BUILD_PIPELINE_INSTANCE_VARS, env.pipelineInstanceVars)
78 | actualOffsetTime = resource.Offset(tl, reference)
79 | })
80 |
81 | AfterEach(func() {
82 | os.Setenv(resource.BUILD_TEAM_NAME, originalTeam)
83 | os.Setenv(resource.BUILD_PIPELINE_NAME, originalPipeline)
84 | os.Setenv(resource.BUILD_PIPELINE_INSTANCE_VARS, originalPipelineInstanceVars)
85 | })
86 |
87 | validateExpectedTime := func() {
88 | Expect(actualOffsetTime.Unix()).To(Equal(expectedOffsetTime.Unix()))
89 | }
90 |
91 | RunIntervalAndOrRangeTests := func() {
92 | var rangeDuration time.Duration
93 |
94 | Context("when a range is not specified", func() {
95 | BeforeEach(func() {
96 | rangeDuration = dayDuration
97 |
98 | tl.Start = nil
99 | tl.Stop = nil
100 | })
101 |
102 | Context("when an interval is not specified", func() {
103 | BeforeEach(func() {
104 | tl.Interval = nil
105 | })
106 |
107 | JustBeforeEach(func() {
108 | expectedOffsetTime = time.Date(reference.Year(), reference.Month(), reference.Day(), 0, 0, 0, 0, loc).Add(time.Duration(rangeDuration.Minutes()*env.hashPercentile) * time.Minute)
109 | })
110 |
111 | Context("when using a team name and pipeline name that generates a very small offset", func() {
112 | BeforeEach(func() { env = xsmallOffset })
113 | It("generates an overnight version", validateExpectedTime)
114 | })
115 |
116 | Context("when using a team name and pipeline name that generates a small offset", func() {
117 | BeforeEach(func() { env = smallOffset })
118 | It("generates an morning version", validateExpectedTime)
119 | })
120 |
121 | Context("when using a team name and pipeline name that generates a large offset", func() {
122 | BeforeEach(func() { env = largeOffset })
123 | It("generates an afternoon version", validateExpectedTime)
124 | })
125 |
126 | Context("when using a team name and pipeline name that generates a very large offset", func() {
127 | BeforeEach(func() { env = xlargeOffset })
128 | It("generates an evening version", validateExpectedTime)
129 | })
130 | })
131 |
132 | Context("when an interval is specified", func() {
133 | var intervalDuration time.Duration
134 |
135 | BeforeEach(func() {
136 | tl.Interval = new(models.Interval)
137 | })
138 |
139 | Context("when the interval can't get any smaller", func() {
140 | BeforeEach(func() {
141 | intervalDuration = time.Minute
142 | *tl.Interval = models.Interval(intervalDuration)
143 | })
144 |
145 | for _, testEnv := range []testEnvironment{xsmallOffset, smallOffset, largeOffset, xlargeOffset} {
146 | Context("for the "+env.teamName+"/"+env.pipelineName+"/"+env.pipelineInstanceVars+" pipeline", func() {
147 | BeforeEach(func() {
148 | env = testEnv
149 | expectedOffsetTime = reference.Truncate(time.Minute)
150 | })
151 | It("returns the reference time", validateExpectedTime)
152 | })
153 | }
154 | })
155 |
156 | Context("when the interval is smaller than the range", func() {
157 | BeforeEach(func() {
158 | intervalDuration = time.Hour
159 | *tl.Interval = models.Interval(intervalDuration)
160 | })
161 |
162 | JustBeforeEach(func() {
163 | expectedOffsetTime = reference.Truncate(intervalDuration).Add(time.Duration(intervalDuration.Minutes()*env.hashPercentile) * time.Minute)
164 | })
165 |
166 | Context("when using a team name and pipeline name that generates a very small offset", func() {
167 | BeforeEach(func() { env = xsmallOffset })
168 | It("generates a version at the very beginning of the interval", validateExpectedTime)
169 | })
170 |
171 | Context("when using a team name and pipeline name that generates a small offset", func() {
172 | BeforeEach(func() { env = smallOffset })
173 | It("generates a version toward the beginning of the interval", validateExpectedTime)
174 | })
175 |
176 | Context("when using a team name and pipeline name that generates a large offset", func() {
177 | BeforeEach(func() { env = largeOffset })
178 | It("generates a version toward the end of the interval", validateExpectedTime)
179 | })
180 |
181 | Context("when using a team name and pipeline name that generates a very large offset", func() {
182 | BeforeEach(func() { env = xlargeOffset })
183 | It("generates a version at the very end of the interval", validateExpectedTime)
184 | })
185 | })
186 |
187 | Context("when the interval is larger than the range", func() {
188 | BeforeEach(func() {
189 | intervalDuration = time.Hour * 168
190 | *tl.Interval = models.Interval(intervalDuration)
191 | })
192 |
193 | JustBeforeEach(func() {
194 | expectedOffsetTime = time.Date(reference.Year(), reference.Month(), reference.Day(), 0, 0, 0, 0, loc).Add(time.Duration(rangeDuration.Minutes()*env.hashPercentile) * time.Minute)
195 | })
196 |
197 | Context("when using a team name and pipeline name that generates a very small offset", func() {
198 | BeforeEach(func() { env = xsmallOffset })
199 | It("generates a version at the very beginning of the interval", validateExpectedTime)
200 | })
201 |
202 | Context("when using a team name and pipeline name that generates a small offset", func() {
203 | BeforeEach(func() { env = smallOffset })
204 | It("generates a version toward the beginning of the interval", validateExpectedTime)
205 | })
206 |
207 | Context("when using a team name and pipeline name that generates a large offset", func() {
208 | BeforeEach(func() { env = largeOffset })
209 | It("generates a version toward the end of the interval", validateExpectedTime)
210 | })
211 |
212 | Context("when using a team name and pipeline name that generates a very large offset", func() {
213 | BeforeEach(func() { env = xlargeOffset })
214 | It("generates a version at the very end of the interval", validateExpectedTime)
215 | })
216 | })
217 | })
218 | })
219 |
220 | Context("when a range is specified", func() {
221 | BeforeEach(func() {
222 | rangeDuration = 6 * time.Hour
223 |
224 | tlStart := reference.Truncate(rangeDuration)
225 | tl.Start = new(models.TimeOfDay)
226 | *tl.Start = models.NewTimeOfDay(tlStart)
227 |
228 | tlStop := tlStart.Add(rangeDuration)
229 | tl.Stop = new(models.TimeOfDay)
230 | *tl.Stop = models.NewTimeOfDay(tlStop)
231 | })
232 |
233 | Context("when an interval is not specified", func() {
234 | BeforeEach(func() {
235 | tl.Interval = nil
236 | })
237 |
238 | JustBeforeEach(func() {
239 | expectedOffsetTime = reference.Truncate(rangeDuration).Add(time.Duration(rangeDuration.Minutes()*env.hashPercentile) * time.Minute)
240 | })
241 |
242 | Context("when using a team name and pipeline name that generates a very small offset", func() {
243 | BeforeEach(func() { env = xsmallOffset })
244 | It("generates a version at the very beginning of the range", validateExpectedTime)
245 | })
246 |
247 | Context("when using a team name and pipeline name that generates a small offset", func() {
248 | BeforeEach(func() { env = smallOffset })
249 | It("generates a version toward the beginning of the range", validateExpectedTime)
250 | })
251 |
252 | Context("when using a team name and pipeline name that generates a large offset", func() {
253 | BeforeEach(func() { env = largeOffset })
254 | It("generates a version toward the end of the range", validateExpectedTime)
255 | })
256 |
257 | Context("when using a team name and pipeline name that generates a very large offset", func() {
258 | BeforeEach(func() { env = xlargeOffset })
259 | It("generates a version at the very end of the range", validateExpectedTime)
260 | })
261 | })
262 |
263 | Context("when an interval is specified", func() {
264 | var intervalDuration time.Duration
265 |
266 | BeforeEach(func() {
267 | tl.Interval = new(models.Interval)
268 | })
269 |
270 | Context("when the interval can't get any smaller", func() {
271 | BeforeEach(func() {
272 | intervalDuration = time.Minute
273 | *tl.Interval = models.Interval(intervalDuration)
274 | })
275 |
276 | for _, testEnv := range []testEnvironment{xsmallOffset, smallOffset, largeOffset, xlargeOffset} {
277 | Context("for the "+env.teamName+"/"+env.pipelineName+"/"+env.pipelineInstanceVars+" pipeline", func() {
278 | BeforeEach(func() {
279 | env = testEnv
280 | expectedOffsetTime = reference.Truncate(time.Minute)
281 | })
282 | It("returns the reference time", validateExpectedTime)
283 | })
284 | }
285 | })
286 |
287 | Context("when the interval is smaller than the range", func() {
288 | BeforeEach(func() {
289 | intervalDuration = time.Hour
290 | *tl.Interval = models.Interval(intervalDuration)
291 | })
292 |
293 | JustBeforeEach(func() {
294 | expectedOffsetTime = reference.Truncate(intervalDuration).Add(time.Duration(intervalDuration.Minutes()*env.hashPercentile) * time.Minute)
295 | })
296 |
297 | Context("when using a team name and pipeline name that generates a very small offset", func() {
298 | BeforeEach(func() { env = xsmallOffset })
299 | It("generates a version at the very beginning of the interval", validateExpectedTime)
300 | })
301 |
302 | Context("when using a team name and pipeline name that generates a small offset", func() {
303 | BeforeEach(func() { env = smallOffset })
304 | It("generates a version toward the beginning of the interval", validateExpectedTime)
305 | })
306 |
307 | Context("when using a team name and pipeline name that generates a large offset", func() {
308 | BeforeEach(func() { env = largeOffset })
309 | It("generates a version toward the end of the interval", validateExpectedTime)
310 | })
311 |
312 | Context("when using a team name and pipeline name that generates a very large offset", func() {
313 | BeforeEach(func() { env = xlargeOffset })
314 | It("generates a version at the very end of the interval", validateExpectedTime)
315 | })
316 | })
317 |
318 | Context("when the interval is larger than the range", func() {
319 | BeforeEach(func() {
320 | intervalDuration = time.Hour * 168
321 | *tl.Interval = models.Interval(intervalDuration)
322 | })
323 |
324 | JustBeforeEach(func() {
325 | expectedOffsetTime = reference.Truncate(rangeDuration).Add(time.Duration(rangeDuration.Minutes()*env.hashPercentile) * time.Minute)
326 | })
327 |
328 | Context("when using a team name and pipeline name that generates a very small offset", func() {
329 | BeforeEach(func() { env = xsmallOffset })
330 | It("generates a version at the very beginning of the range", validateExpectedTime)
331 | })
332 |
333 | Context("when using a team name and pipeline name that generates a small offset", func() {
334 | BeforeEach(func() { env = smallOffset })
335 | It("generates a version toward the beginning of the range", validateExpectedTime)
336 | })
337 |
338 | Context("when using a team name and pipeline name that generates a large offset", func() {
339 | BeforeEach(func() { env = largeOffset })
340 | It("generates a version toward the end of the range", validateExpectedTime)
341 | })
342 |
343 | Context("when using a team name and pipeline name that generates a very large offset", func() {
344 | BeforeEach(func() { env = xlargeOffset })
345 | It("generates a version at the very end of the range", validateExpectedTime)
346 | })
347 | })
348 | })
349 | })
350 | }
351 |
352 | Context("when a location is not specified", func() {
353 | BeforeEach(func() {
354 | loc = time.UTC
355 | tl.Location = nil
356 | reference = time.Date(2012, time.April, 21, now.Hour(), now.Minute(), now.Second(), now.Nanosecond(), loc)
357 | dayDuration = 24 * time.Hour
358 | })
359 |
360 | RunIntervalAndOrRangeTests()
361 | })
362 |
363 | Context("when a location is specified", func() {
364 | BeforeEach(func() {
365 | var err error
366 |
367 | loc, err = time.LoadLocation("America/New_York")
368 | Expect(err).NotTo(HaveOccurred())
369 |
370 | tlLoc := models.Location(*loc)
371 | tl.Location = &tlLoc
372 | })
373 |
374 | Context("when referencing a 23-hour day", func() {
375 | BeforeEach(func() {
376 | reference = time.Date(2012, time.March, 11, 6, now.Minute(), now.Second(), now.Nanosecond(), loc)
377 | dayDuration = 23 * time.Hour
378 | })
379 |
380 | RunIntervalAndOrRangeTests()
381 | })
382 |
383 | Context("when referencing a 25-hour day", func() {
384 | BeforeEach(func() {
385 | reference = time.Date(2012, time.November, 4, 13, now.Minute(), now.Second(), now.Nanosecond(), loc)
386 | dayDuration = 25 * time.Hour
387 | })
388 |
389 | RunIntervalAndOrRangeTests()
390 | })
391 | })
392 | })
393 |
--------------------------------------------------------------------------------
/out/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "encoding/json"
5 | "fmt"
6 | "os"
7 |
8 | resource "github.com/concourse/time-resource"
9 | "github.com/concourse/time-resource/models"
10 | )
11 |
12 | func main() {
13 | var request models.OutRequest
14 |
15 | err := json.NewDecoder(os.Stdin).Decode(&request)
16 | if err != nil {
17 | fmt.Fprintln(os.Stderr, "parse error:", err.Error())
18 | os.Exit(1)
19 | }
20 |
21 | command := resource.OutCommand{}
22 |
23 | response, err := command.Run(request)
24 | if err != nil {
25 | fmt.Fprintln(os.Stderr, "running command:", err.Error())
26 | os.Exit(1)
27 | }
28 |
29 | json.NewEncoder(os.Stdout).Encode(response)
30 | }
31 |
--------------------------------------------------------------------------------
/out_command.go:
--------------------------------------------------------------------------------
1 | package resource
2 |
3 | import (
4 | "time"
5 |
6 | "github.com/concourse/time-resource/models"
7 | )
8 |
9 | type OutCommand struct {
10 | }
11 |
12 | func (*OutCommand) Run(request models.OutRequest) (models.OutResponse, error) {
13 | currentTime := time.Now().UTC()
14 | specifiedLocation := request.Source.Location
15 | if specifiedLocation != nil {
16 | currentTime = currentTime.In((*time.Location)(specifiedLocation))
17 | }
18 |
19 | outVersion := models.Version{Time: currentTime}
20 | response := models.OutResponse{Version: outVersion}
21 |
22 | return response, nil
23 | }
24 |
--------------------------------------------------------------------------------
/out_command_test.go:
--------------------------------------------------------------------------------
1 | package resource_test
2 |
3 | import (
4 | "os"
5 | "strings"
6 | "time"
7 |
8 | resource "github.com/concourse/time-resource"
9 | "github.com/concourse/time-resource/models"
10 | . "github.com/onsi/ginkgo/v2"
11 | . "github.com/onsi/gomega"
12 | )
13 |
14 | var _ = Describe("Out", func() {
15 | var (
16 | now time.Time
17 |
18 | tmpdir string
19 |
20 | source models.Source
21 | response models.OutResponse
22 |
23 | err error
24 | )
25 |
26 | BeforeEach(func() {
27 | now = time.Now().UTC()
28 |
29 | tmpdir, err = os.MkdirTemp("", "out-source")
30 | Expect(err).NotTo(HaveOccurred())
31 |
32 | source = models.Source{}
33 | })
34 |
35 | JustBeforeEach(func() {
36 | command := resource.OutCommand{}
37 | response, err = command.Run(models.OutRequest{
38 | Source: source,
39 | })
40 | })
41 |
42 | AfterEach(func() {
43 | os.RemoveAll(tmpdir)
44 | })
45 |
46 | Context("when executed", func() {
47 | JustBeforeEach(func() {
48 | Expect(err).NotTo(HaveOccurred())
49 | })
50 |
51 | Context("when a location is specified", func() {
52 | BeforeEach(func() {
53 | loc, err := time.LoadLocation("America/Indiana/Indianapolis")
54 | Expect(err).ToNot(HaveOccurred())
55 |
56 | srcLoc := models.Location(*loc)
57 | source.Location = &srcLoc
58 |
59 | now = now.In(loc)
60 | })
61 |
62 | It("reports specified location's current time (offset: -0400) as the version", func() {
63 | _, expectedOffset := now.Zone()
64 |
65 | _, versionOffset := response.Version.Time.Zone()
66 | Expect(versionOffset).To(Equal(expectedOffset))
67 | })
68 | })
69 |
70 | Context("when a location is not specified", func() {
71 | It("reports the current time (offset: 0000) as the version", func() {
72 | // An example of response.Version.Time.String() is
73 | // 2019-04-03 18:53:10.964705 +0000 UTC
74 | contained := strings.Contains(response.Version.Time.String(), "0000")
75 | Expect(contained).To(BeTrue())
76 | })
77 | })
78 | })
79 | })
80 |
--------------------------------------------------------------------------------
/resource_suite_test.go:
--------------------------------------------------------------------------------
1 | package resource_test
2 |
3 | import (
4 | "testing"
5 |
6 | . "github.com/onsi/ginkgo/v2"
7 | . "github.com/onsi/gomega"
8 | "github.com/onsi/gomega/gexec"
9 | )
10 |
11 | var _ = AfterSuite(func() {
12 | gexec.CleanupBuildArtifacts()
13 | })
14 |
15 | func TestTimeResource(t *testing.T) {
16 | RegisterFailHandler(Fail)
17 | RunSpecs(t, "Time Resource Suite")
18 | }
19 |
--------------------------------------------------------------------------------