Skip to content
Open
Show file tree
Hide file tree
Changes from 11 commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
265dfda
feat(connection): support cron time zones via config API
devin-ai-integration[bot] May 23, 2026
f5646bf
fix(connection): address cron timezone CI checks
devin-ai-integration[bot] May 23, 2026
0426c40
fix(connection): align generated artifacts
devin-ai-integration[bot] May 23, 2026
2d5dcf7
fix(connection): regenerate docs version
devin-ai-integration[bot] May 23, 2026
c545b41
fix(connection): match generated provider docs
devin-ai-integration[bot] May 23, 2026
efba595
fix(connection): match generated provider formatting
devin-ai-integration[bot] May 23, 2026
a89fc6a
fix(connection): keep docs provider version stable
devin-ai-integration[bot] May 23, 2026
2423a3b
fix(connection): align zero-diff docs
devin-ai-integration[bot] May 23, 2026
b7193ef
fix(connection): restore generated sdk version
devin-ai-integration[bot] May 23, 2026
1e53738
fix(connection): align provider example version
devin-ai-integration[bot] May 23, 2026
c1fe303
fix(connection): align zero-diff sdk version
devin-ai-integration[bot] May 23, 2026
63406a2
fix(connection): address cron timezone review findings
devin-ai-integration[bot] May 25, 2026
9927066
fix(connection): repair cron timezone post-generation patch
devin-ai-integration[bot] May 25, 2026
2d7efb2
fix(connection): keep resource provider data compatible
devin-ai-integration[bot] May 25, 2026
13f4728
fix(connection): unblock cron timezone generation
devin-ai-integration[bot] May 25, 2026
6dd02dd
fix(connection): align provider generation output
devin-ai-integration[bot] May 25, 2026
fafd318
fix(connection): match regenerated provider output
devin-ai-integration[bot] May 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ terraform {
required_providers {
airbyte = {
source = "airbytehq/airbyte"
version = "1.2.1"
version = "1.3.0"
}
}
}
Expand All @@ -37,6 +37,7 @@ provider "airbyte" {
- `bearer_auth` (String, Sensitive) HTTP Bearer.
- `client_id` (String, Sensitive) OAuth2 Client Credentials Flow client identifier.
- `client_secret` (String, Sensitive) OAuth2 Client Credentials Flow client secret.
- `config_api_root` (String) Internal config API root used for connection schedule features not exposed by the public API (defaults to the corresponding Airbyte config API for server_url).
- `password` (String, Sensitive) HTTP Basic password.
- `server_url` (String) Server URL (defaults to https://api.airbyte.com/v1)
- `token_url` (String, Sensitive) OAuth2 Client Credentials Flow token URL.
Expand Down
1 change: 1 addition & 0 deletions docs/resources/connection.md
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,7 @@ Optional:
Optional:

- `cron_expression` (String)
- `cron_time_zone` (String) IANA time zone used to evaluate cron schedules, for example "America/New_York". Defaults to Airbyte's API default when omitted.
- `schedule_type` (String) Not Null; must be one of ["manual", "cron"]

Read-Only:
Expand Down
2 changes: 1 addition & 1 deletion examples/provider/provider.tf
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ terraform {
required_providers {
airbyte = {
source = "airbytehq/airbyte"
version = "1.2.1"
version = "1.3.0"
}
}
}
Expand Down
284 changes: 284 additions & 0 deletions internal/provider/connection_cron_timezone.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,284 @@
package provider

import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"sync"
"time"

tfTypes "github.com/airbytehq/terraform-provider-airbyte/internal/provider/types"
"github.com/airbytehq/terraform-provider-airbyte/internal/sdk"
"github.com/hashicorp/terraform-plugin-framework/diag"
"github.com/hashicorp/terraform-plugin-framework/types"
)

type providerRuntimeConfig struct {
ConfigAPIRoot string
}

var providerRuntimeConfigs sync.Map

func storeProviderRuntimeConfig(client *sdk.SDK, config providerRuntimeConfig) {
providerRuntimeConfigs.Store(client, config)
}

func getProviderRuntimeConfig(client *sdk.SDK) providerRuntimeConfig {
if config, ok := providerRuntimeConfigs.Load(client); ok {
return config.(providerRuntimeConfig)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. Runtime config is now stored on the AirbyteProvider instance and injected only into ConnectionResource; resp.ResourceData remains the generated *sdk.SDK, so there is no package-level map retaining clients.


Devin session

return providerRuntimeConfig{}
}

func deriveConfigAPIRoot(publicAPIRoot string) string {
publicAPIRoot = strings.TrimRight(publicAPIRoot, "/")
if strings.Contains(publicAPIRoot, "api.airbyte.com") {
return "https://cloud.airbyte.com/api"
}
if strings.HasSuffix(publicAPIRoot, "/api/public/v1") {
return strings.TrimSuffix(publicAPIRoot, "/public/v1")
}
if strings.HasSuffix(publicAPIRoot, "/api/v1") {
return strings.TrimSuffix(publicAPIRoot, "/v1")
}
return strings.TrimSuffix(publicAPIRoot, "/v1")
}

func stripCronTimeZone(cronExpression string) (string, string) {
fields := strings.Fields(cronExpression)
if len(fields) <= 6 {
return cronExpression, ""
}

last := fields[len(fields)-1]
if _, err := time.LoadLocation(last); err != nil {
return cronExpression, ""
}

return strings.Join(fields[:len(fields)-1], " "), last
}

func stringPointerValue(value string) *string {
if value == "" {
return nil
}
return &value
}

func cronScheduleParts(schedule *tfTypes.AirbyteAPIConnectionSchedule) (string, string, diag.Diagnostics) {
var diags diag.Diagnostics
if schedule == nil || schedule.ScheduleType.ValueString() != "cron" {
return "", "", diags
}

cronExpression := schedule.CronExpression.ValueString()
if cronExpression == "" {
return "", "", diags
}

cleanCronExpression, suffixTimeZone := stripCronTimeZone(cronExpression)
cronTimeZone := suffixTimeZone
if !schedule.CronTimeZone.IsUnknown() && !schedule.CronTimeZone.IsNull() && schedule.CronTimeZone.ValueString() != "" {
cronTimeZone = schedule.CronTimeZone.ValueString()
}
if cronTimeZone == "" {
return cleanCronExpression, "", diags
}

if _, err := time.LoadLocation(cronTimeZone); err != nil {
diags.AddError("Invalid cron time zone", fmt.Sprintf("cron_time_zone must be a valid IANA time zone: %s", err))
return "", "", diags
}

return cleanCronExpression, cronTimeZone, diags
}

func cronExpressionForPublicAPI(schedule *tfTypes.AirbyteAPIConnectionSchedule) *string {
if schedule == nil || schedule.CronExpression.IsUnknown() || schedule.CronExpression.IsNull() {
return nil
}
cronExpression, _ := stripCronTimeZone(schedule.CronExpression.ValueString())
return stringPointerValue(cronExpression)
}

func applyCronScheduleResponse(schedule *tfTypes.AirbyteAPIConnectionSchedule, cronExpression *string, cronTimeZone *string) {
if schedule == nil {
return
}

if cronExpression == nil {
schedule.CronExpression = types.StringNull()
schedule.CronTimeZone = types.StringPointerValue(cronTimeZone)
return
}

cleanCronExpression, suffixTimeZone := stripCronTimeZone(*cronExpression)
if cronTimeZone == nil && suffixTimeZone != "" {
cronTimeZone = &suffixTimeZone
}

schedule.CronExpression = types.StringValue(cleanCronExpression)
schedule.CronTimeZone = types.StringPointerValue(cronTimeZone)
}

func applyCronScheduleDataSourceResponse(schedule *tfTypes.ConnectionScheduleResponse, cronExpression *string, cronTimeZone *string) {
if schedule == nil {
return
}

if cronExpression == nil {
schedule.CronExpression = types.StringNull()
schedule.CronTimeZone = types.StringPointerValue(cronTimeZone)
return
}

cleanCronExpression, suffixTimeZone := stripCronTimeZone(*cronExpression)
if cronTimeZone == nil && suffixTimeZone != "" {
cronTimeZone = &suffixTimeZone
}

schedule.CronExpression = types.StringValue(cleanCronExpression)
schedule.CronTimeZone = types.StringPointerValue(cronTimeZone)
}

type configConnectionScheduleData struct {
Cron *configConnectionScheduleDataCron `json:"cron,omitempty"`
}

type configConnectionScheduleDataCron struct {
CronExpression string `json:"cronExpression"`
CronTimeZone string `json:"cronTimeZone"`
}

type configConnectionRead struct {
ScheduleType string `json:"scheduleType"`
ScheduleData *configConnectionScheduleData `json:"scheduleData,omitempty"`
}

func (r *ConnectionResource) applyCronTimeZone(ctx context.Context, data *ConnectionResourceModel, connectionID string, rawResponse *http.Response) diag.Diagnostics {
var diags diag.Diagnostics

cronExpression, cronTimeZone, scheduleDiags := cronScheduleParts(data.Schedule)
diags.Append(scheduleDiags...)
if diags.HasError() || cronExpression == "" || cronTimeZone == "" || cronTimeZone == "UTC" {
return diags
}

authHeader := authorizationHeader(rawResponse)
if authHeader == "" {
diags.AddError("Unable to apply cron time zone", "The Airbyte API response did not include an Authorization header to reuse for the internal config API request.")
return diags
}

body := map[string]any{
"connectionId": connectionID,
"scheduleType": "cron",
"scheduleData": configConnectionScheduleData{
Cron: &configConnectionScheduleDataCron{
CronExpression: cronExpression,
CronTimeZone: cronTimeZone,
},
},
}

var out configConnectionRead
if err := r.postConfigAPI(ctx, "/v1/connections/update", authHeader, body, &out); err != nil {
diags.AddError("Unable to apply cron time zone", err.Error())
return diags
}

if out.ScheduleData != nil && out.ScheduleData.Cron != nil {
applyCronScheduleResponse(data.Schedule, &out.ScheduleData.Cron.CronExpression, &out.ScheduleData.Cron.CronTimeZone)
}

return diags
}

func (r *ConnectionResource) refreshCronTimeZone(ctx context.Context, data *ConnectionResourceModel, rawResponse *http.Response) diag.Diagnostics {
var diags diag.Diagnostics
if data == nil || data.ConnectionID.IsNull() || data.ConnectionID.IsUnknown() {
return diags
}

authHeader := authorizationHeader(rawResponse)
if authHeader == "" {
return diags
}

body := map[string]string{"connectionId": data.ConnectionID.ValueString()}

var out configConnectionRead
if err := r.postConfigAPI(ctx, "/v1/connections/get", authHeader, body, &out); err != nil {
return diags
}
Comment on lines +214 to +221

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. Reads now preserve the previous planned/state timezone on config API refresh failure and add a warning diagnostic instead of silently clearing cron_time_zone.


Devin session


if out.ScheduleType != "cron" || out.ScheduleData == nil || out.ScheduleData.Cron == nil {
return diags
}

if data.Schedule == nil {
data.Schedule = &tfTypes.AirbyteAPIConnectionSchedule{}
}
applyCronScheduleResponse(data.Schedule, &out.ScheduleData.Cron.CronExpression, &out.ScheduleData.Cron.CronTimeZone)

return diags
}

func authorizationHeader(response *http.Response) string {
if response == nil || response.Request == nil {
return ""
}
return response.Request.Header.Get("Authorization")
}

func (r *ConnectionResource) postConfigAPI(ctx context.Context, path string, authHeader string, body any, out any) error {
config := getProviderRuntimeConfig(r.client)
if config.ConfigAPIRoot == "" {
return fmt.Errorf("config_api_root is not configured")
}

endpoint, err := url.JoinPath(config.ConfigAPIRoot, path)
if err != nil {
return err
}

payload, err := json.Marshal(body)
if err != nil {
return err
}

request, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(payload))
if err != nil {
return err
}
request.Header.Set("Accept", "application/json")
request.Header.Set("Content-Type", "application/json")
request.Header.Set("Authorization", authHeader)

response, err := http.DefaultClient.Do(request)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. providerRuntimeConfig now carries the configured *http.Client, and the config API helper uses that client rather than http.DefaultClient.


Devin session

if err != nil {
return err
}
defer func() {
_ = response.Body.Close()
}()

responseBody, err := io.ReadAll(response.Body)
if err != nil {
return err
}

if response.StatusCode < 200 || response.StatusCode >= 300 {
return fmt.Errorf("internal config API returned status %d: %s", response.StatusCode, string(responseBody))
}

if out == nil || len(responseBody) == 0 {
return nil
}
return json.Unmarshal(responseBody, out)
}
2 changes: 1 addition & 1 deletion internal/provider/connection_data_source_sdk.go
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ func (r *ConnectionDataSourceModel) RefreshFromSharedConnectionResponse(ctx cont
r.Prefix = types.StringPointerValue(resp.Prefix)
r.Schedule = &tfTypes.ConnectionScheduleResponse{}
r.Schedule.BasicTiming = types.StringPointerValue(resp.Schedule.BasicTiming)
r.Schedule.CronExpression = types.StringPointerValue(resp.Schedule.CronExpression)
applyCronScheduleDataSourceResponse(r.Schedule, resp.Schedule.CronExpression, nil)
r.Schedule.ScheduleType = types.StringValue(string(resp.Schedule.ScheduleType))
r.SourceID = types.StringValue(resp.SourceID)
r.Status = types.StringValue(string(resp.Status))
Expand Down
11 changes: 11 additions & 0 deletions internal/provider/connection_resource.go
Original file line number Diff line number Diff line change
Expand Up @@ -612,6 +612,14 @@ func (r *ConnectionResource) Schema(ctx context.Context, req resource.SchemaRequ
speakeasy_stringplanmodifier.SuppressDiff(speakeasy_stringplanmodifier.ExplicitSuppress),
},
},
"cron_time_zone": schema.StringAttribute{
Computed: true,
Optional: true,
PlanModifiers: []planmodifier.String{
speakeasy_stringplanmodifier.SuppressDiff(speakeasy_stringplanmodifier.ExplicitSuppress),
},
Description: `IANA time zone used to evaluate cron schedules, for example "America/New_York". Defaults to Airbyte's API default when omitted.`,
},
"schedule_type": schema.StringAttribute{
Computed: true,
Optional: true,
Expand Down Expand Up @@ -796,6 +804,7 @@ func (r *ConnectionResource) Create(ctx context.Context, req resource.CreateRequ
return
}
resp.Diagnostics.Append(data.RefreshFromSharedConnectionResponse(ctx, res.ConnectionResponse)...)
resp.Diagnostics.Append(r.applyCronTimeZone(ctx, data, res.ConnectionResponse.ConnectionID, res.RawResponse)...)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 applyCronTimeZone reads plan's CronTimeZone after RefreshFromSharedConnectionResponse has overwritten it to null

In both Create and Update flows, RefreshFromSharedConnectionResponse is called before applyCronTimeZone. The refresh calls applyCronScheduleResponse(r.Schedule, resp.Schedule.CronExpression, nil) (connection_resource_sdk.go:148), which sets schedule.CronTimeZone = types.StringPointerValue(nil) (null). Then applyCronTimeZone calls cronScheduleParts(data.Schedule) (connection_cron_timezone.go:166), which checks !schedule.CronTimeZone.IsNull() — but it IS null, so cronTimeZone stays "", and the function returns early without ever calling the config API. The user's cron_time_zone value is never applied to the server. Although refreshPlan later restores the plan value into state, the server-side timezone remains at the default (UTC), causing perpetual drift on every subsequent plan/apply cycle.

Prompt for agents
In connection_resource.go, the Create flow (lines 806-807) and Update flow (lines 922-923), applyCronTimeZone is called after RefreshFromSharedConnectionResponse, which overwrites data.Schedule.CronTimeZone to null. The applyCronTimeZone function then reads the null CronTimeZone and returns early without calling the config API.

The fix should preserve the user's planned CronTimeZone value before RefreshFromSharedConnectionResponse overwrites it, then pass it to applyCronTimeZone. One approach:

1. Before calling RefreshFromSharedConnectionResponse, save the planned timezone:
   var plannedCronTimeZone string
   if data.Schedule != nil {
       plannedCronTimeZone = data.Schedule.CronTimeZone.ValueString()
   }

2. Modify applyCronTimeZone to accept the planned timezone as a parameter instead of reading it from data.Schedule.CronTimeZone.

Alternatively, change cronScheduleParts to accept an explicit timezone string parameter rather than reading from the schedule struct.

This needs to be fixed in both the Create flow (around line 806) and the Update flow (around line 922). Since these are generated files patched via scripts/patch_connection_cron_timezone.py, the actual fix should be applied in the patch script (patch_connection_resource_go function) and in the hand-written connection_cron_timezone.go file.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. Create and Update now preserve the planned cron_time_zone before RefreshFromSharedConnectionResponse resets generated schedule fields, then pass that preserved value into applyCronTimeZone.


Devin session


if resp.Diagnostics.HasError() {
return
Expand Down Expand Up @@ -860,6 +869,7 @@ func (r *ConnectionResource) Read(ctx context.Context, req resource.ReadRequest,
return
}
resp.Diagnostics.Append(data.RefreshFromSharedConnectionResponse(ctx, res.ConnectionResponse)...)
resp.Diagnostics.Append(r.refreshCronTimeZone(ctx, data, res.RawResponse)...)

if resp.Diagnostics.HasError() {
return
Expand Down Expand Up @@ -910,6 +920,7 @@ func (r *ConnectionResource) Update(ctx context.Context, req resource.UpdateRequ
return
}
resp.Diagnostics.Append(data.RefreshFromSharedConnectionResponse(ctx, res.ConnectionResponse)...)
resp.Diagnostics.Append(r.applyCronTimeZone(ctx, data, request.ConnectionID, res.RawResponse)...)

if resp.Diagnostics.HasError() {
return
Expand Down
Loading
Loading